task: форк fractal-ui
This commit is contained in:
Generated
+1175
-6
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export * from './src';
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@msb/fractal-ui-composites",
|
||||
"version": "30.15.1",
|
||||
"description": "Составные компоненты дизайн-системы Fractal",
|
||||
"publishConfig": {
|
||||
"registry": "https://nexus.gboteam.ru/repository/npm-eco-hosted/"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.es.js",
|
||||
"files": ["dist", "dist-types", "CHANGELOG.md"],
|
||||
"types": "dist-types/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "rollup -c ../../rollup.config.ts --configPlugin rollup-plugin-typescript2",
|
||||
"clean": "rimraf -rf ./dist && rimraf -rf ./dist-types && rimraf -rf ./node_modules",
|
||||
"compile": "tsc --noEmit"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@msb/fractal-ui-core": "30.15.0",
|
||||
"@msb/fractal-ui-extended": "30.15.0",
|
||||
"@fractal-ui/library": "30.15.0",
|
||||
"@msb/fractal-ui-overlays": "30.15.0",
|
||||
"@msb/fractal-ui-styling": "30.14.2",
|
||||
"@types/react": "17.0.36",
|
||||
"@types/react-router-dom": "5.1.7",
|
||||
"@types/styled-system": "5.1.15",
|
||||
"@types/styled-system__css": "5.0.16",
|
||||
"dayjs": "1.11.3",
|
||||
"imask": "6.6.3",
|
||||
"react": "17.0.2",
|
||||
"react-animate-height": "2.0.23",
|
||||
"react-dom": "17.0.2",
|
||||
"react-router-dom": "5.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "11.8.1",
|
||||
"@emotion/styled": "11.8.1",
|
||||
"@msb/fractal-ui-core": "*",
|
||||
"@msb/fractal-ui-extended": "*",
|
||||
"@fractal-ui/library": "*",
|
||||
"@msb/fractal-ui-overlays": "*",
|
||||
"@msb/fractal-ui-styling": "*",
|
||||
"@styled-system/css": "5.1.5",
|
||||
"@types/styled-system": "5.1.15",
|
||||
"dayjs": "1.11.3",
|
||||
"final-form": "4.20.10",
|
||||
"react": "17.0.2",
|
||||
"react-animate-height": "2.0.23",
|
||||
"react-dnd": "11.1.3",
|
||||
"react-dnd-html5-backend": "11.1.3",
|
||||
"react-dom": "17.0.2",
|
||||
"react-final-form": "6.5.3",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-table": "7.7.0",
|
||||
"styled-system": "5.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "node_modules/cz-customizable"
|
||||
}
|
||||
},
|
||||
"browserslist": ["extends @eco/browserslist-config"]
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import { Title, Controls, Description, Source, Heading, Subheading, Canvas } from '@storybook/blocks';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { StoryCode, StoryLi } from 'common';
|
||||
import Breadcrumbs from '..';
|
||||
import { items } from './constants';
|
||||
|
||||
import { AdaptiveStory, SandboxStory, SizeStory, UseCasesStory } from './examples';
|
||||
|
||||
export { AdaptiveStory, SandboxStory, SizeStory, UseCasesStory };
|
||||
SandboxStory.storyName = 'Песочница';
|
||||
SandboxStory.args = {
|
||||
items,
|
||||
};
|
||||
|
||||
AdaptiveStory.storyName = 'Адаптивность';
|
||||
SizeStory.storyName = 'Размеры';
|
||||
UseCasesStory.storyName = 'Варианты ипользования';
|
||||
|
||||
const Docs: React.VFC = () => (
|
||||
<>
|
||||
<Title>Breadcrumbs</Title>
|
||||
<Description of={Breadcrumbs} />
|
||||
<Source
|
||||
code={`
|
||||
// импорт компонента Breadcrumbs
|
||||
import { Breadcrumbs } from '@msb/fractal-ui-composites';
|
||||
|
||||
const items = [
|
||||
{
|
||||
text: 'Раздел 1',
|
||||
onClick: () => { handleClick('Раздел 1') },
|
||||
},
|
||||
{
|
||||
text: 'Раздел 2',
|
||||
onClick: () => { handleClick('Раздел 2') },
|
||||
},
|
||||
{
|
||||
text: 'Раздел 3',
|
||||
onClick: () => { handleClick('Раздел 3') },
|
||||
},
|
||||
{
|
||||
text: 'Раздел 4',
|
||||
onClick: () => { handleClick('Раздел 4') },
|
||||
},
|
||||
]
|
||||
|
||||
<Breadcrumbs items={items} />
|
||||
`}
|
||||
language="tsx"
|
||||
/>
|
||||
|
||||
<Heading>Адаптивность</Heading>
|
||||
<p>
|
||||
Если пунктов в крошках столько, что они не вмещаются в ширину экрана, то предпоследний пункт крошек превращается в выпадающий список,
|
||||
пунктами которого являются все не вместившиеся крошки, кроме последнего пункта.
|
||||
</p>
|
||||
<p>
|
||||
На мобильном экране компонент отображается как иконка "Назад". При клике происходит обработка события для последнего элемента в
|
||||
массиве items.
|
||||
</p>
|
||||
<Canvas of={AdaptiveStory} />
|
||||
|
||||
<Heading>Размеры</Heading>
|
||||
<p>
|
||||
У компонента есть только один размер <StoryCode>S</StoryCode>. Он используется для всех ширин экранов.
|
||||
</p>
|
||||
<Canvas of={SizeStory} />
|
||||
|
||||
<Heading>Автотесты</Heading>
|
||||
<p>Компонент Breadcrumbs имеет следующие атрибуты, описанные ниже.</p>
|
||||
<Subheading>[data-role]</Subheading>
|
||||
<p>
|
||||
<ol>
|
||||
<StoryLi>
|
||||
компонент Breadcrumbs - <StoryCode>breadcrumb</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
каждый элемент Breadcrumbs - <StoryCode>item</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
кнопка "Еще"- <StoryCode>button</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
выпадающий список - <StoryCode>listbox</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
каждый элемент выпадающего списка - <StoryCode>item</StoryCode>
|
||||
</StoryLi>
|
||||
</ol>
|
||||
</p>
|
||||
<Subheading>[data-action]</Subheading>
|
||||
<p>
|
||||
Кнопка еще имеет атрибут <StoryCode>[data-action]</StoryCode> со значением <StoryCode>more</StoryCode>
|
||||
</p>
|
||||
<Subheading>[data-name]</Subheading>
|
||||
<p>
|
||||
Выпадающий список имеет атрибут <StoryCode>[data-name]</StoryCode> со значением <StoryCode>breadcrumbs</StoryCode>
|
||||
</p>
|
||||
|
||||
<Heading>Варианты использования</Heading>
|
||||
<Canvas of={UseCasesStory} />
|
||||
|
||||
<Heading>Песочница</Heading>
|
||||
<Canvas of={SandboxStory} />
|
||||
|
||||
<Heading>API</Heading>
|
||||
<Controls of={SandboxStory} />
|
||||
</>
|
||||
);
|
||||
|
||||
const StoryMeta: Meta = {
|
||||
title: 'Навигация/Breadcrumbs',
|
||||
component: Breadcrumbs,
|
||||
parameters: {
|
||||
docs: {
|
||||
page: Docs,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default StoryMeta;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { type BreadcrumbItem } from '../types';
|
||||
|
||||
export const items: BreadcrumbItem[] = [
|
||||
{
|
||||
text: 'Раздел 1',
|
||||
onClick: action('Раздел 1'),
|
||||
},
|
||||
{
|
||||
text: 'Раздел 2',
|
||||
onClick: action('Раздел 2'),
|
||||
},
|
||||
{
|
||||
text: 'Раздел 3',
|
||||
onClick: action('Раздел 3'),
|
||||
},
|
||||
{
|
||||
text: 'Раздел 4',
|
||||
onClick: action('Раздел 4'),
|
||||
},
|
||||
{
|
||||
text: 'Раздел 5',
|
||||
onClick: action('Раздел 5'),
|
||||
},
|
||||
{
|
||||
text: 'Раздел 6',
|
||||
onClick: action('Раздел 6'),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import type { StoryFn } from '@storybook/react';
|
||||
import Breadcrumbs from '..';
|
||||
import { type BreadcrumbsProps } from '../types';
|
||||
import { items } from './constants';
|
||||
|
||||
export const SandboxStory: StoryFn<BreadcrumbsProps> = args => (
|
||||
<>
|
||||
<Breadcrumbs {...args} />
|
||||
<p>У контейнера ширина ограничена в 350px</p>
|
||||
<div style={{ width: '350px' }}>
|
||||
<Breadcrumbs {...args} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export const AdaptiveStory: StoryFn<BreadcrumbsProps> = () => (
|
||||
<>
|
||||
<p>У контейнера ширина ограничена в 350px</p>
|
||||
<div style={{ width: '350px' }}>
|
||||
<Breadcrumbs items={items} />
|
||||
</div>
|
||||
<p> </p>
|
||||
<p>Меняйте размер канвы, чтобы увидеть поведение компонента на мобильном экране</p>
|
||||
<Breadcrumbs items={items} />
|
||||
</>
|
||||
);
|
||||
|
||||
export const SizeStory: StoryFn<BreadcrumbsProps> = () => (
|
||||
<>
|
||||
<h4>Размер S</h4>
|
||||
<Breadcrumbs items={items} />
|
||||
</>
|
||||
);
|
||||
|
||||
export const UseCasesStory: StoryFn<BreadcrumbsProps> = () => (
|
||||
<>
|
||||
<p>Хлебные крошки могут располагаться в одну линию друг за другом.</p>
|
||||
<Breadcrumbs items={items} />
|
||||
<p> </p>
|
||||
<p>
|
||||
Если хлебные крошки содержат только один элемент (пользователь находится на первом уровне вложенности), то компонент отображается как
|
||||
"кнопка назад"
|
||||
</p>
|
||||
<Breadcrumbs items={[items[0]]} />
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { mockDocumentFontsReady, mockObservers } from 'common';
|
||||
import Breadcrumbs from '..';
|
||||
|
||||
const handleClick = jest.fn();
|
||||
|
||||
const items = [
|
||||
{ text: 'Раздел 1', onClick: handleClick },
|
||||
{ text: 'Раздел 2', onClick: handleClick },
|
||||
{ text: 'Раздел 3', onClick: handleClick },
|
||||
{ text: 'Раздел 4', onClick: handleClick },
|
||||
];
|
||||
|
||||
const itemsLarge = [
|
||||
{ text: 'Раздел 1', onClick: handleClick },
|
||||
{ text: 'Раздел 2', onClick: handleClick },
|
||||
{ text: 'Раздел 3', onClick: handleClick },
|
||||
{ text: 'Раздел 4', onClick: handleClick },
|
||||
{ text: 'Раздел 5', onClick: handleClick },
|
||||
];
|
||||
|
||||
jest.mock('../../../../styling/src/hooks/use-breakpoints');
|
||||
|
||||
describe('Breadcrumbs Autotest', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
mockDocumentFontsReady();
|
||||
});
|
||||
|
||||
beforeEach(mockObservers);
|
||||
|
||||
const query = '[data-role="breadcrumbs"]';
|
||||
const itemQuery = '[data-role="item"]';
|
||||
const buttonQuery = '[data-role="button"][data-action="more"]';
|
||||
|
||||
it('имеет атрибут [data-role="breadcrumbs"]', async () => {
|
||||
const { container } = render(<Breadcrumbs items={items} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(query)).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('каждая хлебная крошка имеет атрибут [data-role="item"]', async () => {
|
||||
const { container } = render(<Breadcrumbs items={items} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(itemQuery)).toHaveLength(4));
|
||||
});
|
||||
|
||||
it('кнопка "Еще" имеет атрибуты [data-role="button"] и [data-action="more"]', async () => {
|
||||
jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(500);
|
||||
|
||||
const { container } = render(<Breadcrumbs items={itemsLarge} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(buttonQuery)).toHaveLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Breadcrumbs Dropdown Autotest', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(500);
|
||||
|
||||
mockObservers();
|
||||
});
|
||||
|
||||
it('dropdown имеет атрибуты [data-role="listbox"] и [data-name="breadcrumbs"]', async () => {
|
||||
const listQuery = '[data-role="listbox"][data-name="breadcrumbs"]';
|
||||
|
||||
render(<Breadcrumbs items={itemsLarge} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const btnMore = screen.getByRole('button');
|
||||
|
||||
userEvent.click(btnMore);
|
||||
|
||||
const popup = screen.getByTestId('popup-container');
|
||||
|
||||
expect(popup.querySelectorAll(listQuery)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('каждый элемент dropdown имеет атрибут [data-role="item"]', async () => {
|
||||
const itemQuery = '[data-role="item"]';
|
||||
|
||||
render(<Breadcrumbs items={itemsLarge} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const btnMore = screen.getByRole('button');
|
||||
|
||||
fireEvent.click(btnMore);
|
||||
|
||||
const listbox = screen.getByTestId('popup-container');
|
||||
|
||||
expect(listbox.querySelectorAll(itemQuery)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { type BreakpointValue, useBreakpoints } from '@msb/fractal-ui-styling';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { mockDocumentFontsReady, mockObservers } from 'common';
|
||||
import Breadcrumbs from '..';
|
||||
import BreadcrumbsItem from '../components/breadcrumbs-item';
|
||||
|
||||
const handleClick = jest.fn();
|
||||
const firstItemClick = jest.fn();
|
||||
const secondItemClick = jest.fn();
|
||||
const lastItemClick = jest.fn();
|
||||
|
||||
const items = [
|
||||
{ text: 'Раздел 1', onClick: firstItemClick },
|
||||
{ text: 'Раздел 2', onClick: secondItemClick },
|
||||
{ text: 'Раздел 3', onClick: handleClick },
|
||||
{ text: 'Раздел 4', onClick: lastItemClick },
|
||||
];
|
||||
|
||||
const itemsLarge = [
|
||||
{ text: 'Раздел 1', onClick: handleClick },
|
||||
{ text: 'Раздел 2', onClick: handleClick },
|
||||
{ text: 'Раздел 3', onClick: handleClick },
|
||||
{ text: 'Раздел 4', onClick: handleClick },
|
||||
{ text: 'Раздел 5', onClick: handleClick },
|
||||
];
|
||||
|
||||
const buttonMoreId = 'button-more';
|
||||
|
||||
const activeItemId = 'active-item';
|
||||
|
||||
jest.mock('../../../../styling/src/hooks/use-breakpoints');
|
||||
|
||||
describe('Breadcrumbs', () => {
|
||||
beforeAll(() => {
|
||||
mockObservers();
|
||||
mockDocumentFontsReady();
|
||||
});
|
||||
|
||||
it('отображаются все разделы хлебных крошек', async () => {
|
||||
render(<Breadcrumbs items={items} />);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByTestId('item')).toHaveLength(4));
|
||||
});
|
||||
|
||||
it('при нажатии на раздел вызывается обработчик', async () => {
|
||||
render(<Breadcrumbs items={items} />);
|
||||
|
||||
await waitFor(() => userEvent.click(screen.getAllByTestId('item')[0]));
|
||||
expect(firstItemClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('рендер хлебной крошки', async () => {
|
||||
render(<BreadcrumbsItem item={items[0]} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Раздел 1')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('при нажатии кнопки ArrowRight выделяется следующий пункт', async () => {
|
||||
render(<Breadcrumbs items={items} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const container = screen.getByTestId('breadcrumbs');
|
||||
|
||||
fireEvent.focus(container);
|
||||
|
||||
fireEvent.keyDown(container, { key: 'ArrowRight' });
|
||||
|
||||
const activeItem = screen.getByTestId(activeItemId);
|
||||
|
||||
expect(activeItem).toHaveTextContent('Раздел 2');
|
||||
});
|
||||
});
|
||||
|
||||
it('при нажатии кнопки ArrowLeft выделяется предыдущий пункт', async () => {
|
||||
render(<Breadcrumbs items={items} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const container = screen.getByTestId('breadcrumbs');
|
||||
|
||||
fireEvent.focus(container);
|
||||
|
||||
fireEvent.keyDown(container, { key: 'ArrowRight' });
|
||||
fireEvent.keyDown(container, { key: 'ArrowRight' });
|
||||
fireEvent.keyDown(container, { key: 'ArrowLeft' });
|
||||
|
||||
const activeItem = screen.getByTestId(activeItemId);
|
||||
|
||||
expect(activeItem).toHaveTextContent('Раздел 1');
|
||||
});
|
||||
});
|
||||
|
||||
it('нажатие кнопки ArrowLeft на первом пункте, выделяет последний', async () => {
|
||||
render(<Breadcrumbs items={items} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const container = screen.getByTestId('breadcrumbs');
|
||||
|
||||
fireEvent.focus(container);
|
||||
|
||||
fireEvent.keyDown(container, { key: 'ArrowRight' });
|
||||
fireEvent.keyDown(container, { key: 'ArrowLeft' });
|
||||
|
||||
const activeItem = screen.getByTestId(activeItemId);
|
||||
|
||||
expect(activeItem).toHaveTextContent('Раздел 4');
|
||||
});
|
||||
});
|
||||
|
||||
it('нажатие кнопки ArrowRight на последнем пункте, выделяет первый', async () => {
|
||||
render(<Breadcrumbs items={items} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const container = screen.getByTestId('breadcrumbs');
|
||||
|
||||
fireEvent.focus(container);
|
||||
|
||||
fireEvent.keyDown(container, { key: 'ArrowRight' });
|
||||
fireEvent.keyDown(container, { key: 'ArrowRight' });
|
||||
fireEvent.keyDown(container, { key: 'ArrowRight' });
|
||||
fireEvent.keyDown(container, { key: 'ArrowRight' });
|
||||
fireEvent.keyDown(container, { key: 'ArrowRight' });
|
||||
|
||||
const activeItem = screen.getByTestId(activeItemId);
|
||||
|
||||
expect(activeItem).toHaveTextContent('Раздел 1');
|
||||
});
|
||||
});
|
||||
|
||||
it('нажатие Enter вызывает обработчик item', async () => {
|
||||
render(<Breadcrumbs items={items} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const container = screen.getByTestId('breadcrumbs');
|
||||
|
||||
fireEvent.focus(container);
|
||||
|
||||
fireEvent.keyDown(container, { key: 'ArrowRight' });
|
||||
fireEvent.keyDown(container, { key: 'Enter' });
|
||||
|
||||
expect(firstItemClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('событие Blur снимает выделение с пунктов', async () => {
|
||||
render(<Breadcrumbs items={items} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const container = screen.getByTestId('breadcrumbs');
|
||||
|
||||
fireEvent.focus(container);
|
||||
|
||||
fireEvent.keyDown(container, { key: 'ArrowRight' });
|
||||
|
||||
const activeItem = screen.getByTestId(activeItemId);
|
||||
|
||||
expect(activeItem).toHaveTextContent('Раздел 1');
|
||||
|
||||
fireEvent.blur(container);
|
||||
|
||||
const activeAfterBlur = screen.queryByTestId(activeItemId);
|
||||
|
||||
expect(activeAfterBlur).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('если хлебная крошка только одна, то отображается "К разделу"', async () => {
|
||||
render(<Breadcrumbs items={[items[0]]} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('back-item')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('отображается кнопка "Еще"', async () => {
|
||||
jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(500);
|
||||
|
||||
render(<Breadcrumbs items={itemsLarge} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const btnMore = screen.getByTestId(buttonMoreId);
|
||||
|
||||
expect(btnMore).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('на мобильном экране отображается только иконка', async () => {
|
||||
const mockUseBreakpoints = useBreakpoints as jest.MockedFunction<typeof useBreakpoints> & {
|
||||
setReturnValue(breakpoint: BreakpointValue): void;
|
||||
};
|
||||
|
||||
mockUseBreakpoints.setReturnValue('XS');
|
||||
|
||||
render(<Breadcrumbs items={items} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('mobile-breadcrumbs')).toBeInTheDocument());
|
||||
|
||||
mockUseBreakpoints.mockClear();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { ROLE } from '@msb/fractal-ui-core';
|
||||
import { LongLeftIcon } from '@fractal-ui/library';
|
||||
import { Text, Wrapper } from '@msb/fractal-ui-styling';
|
||||
import { TYPOGRAPHY_COLOR } from '../constants';
|
||||
import { styledIconProps, TypographyWrapper } from '../styled';
|
||||
import type { BreadcrumbsItemProps } from '../types';
|
||||
import Divider from './divider';
|
||||
|
||||
/**
|
||||
* Раздел компонента Breadcrumbs.
|
||||
*/
|
||||
const BreadcrumbsItem = React.forwardRef<HTMLDivElement, BreadcrumbsItemProps>(({ item, isBackItem, active }, ref) => (
|
||||
<Wrapper ref={ref} alignItems="center" display="flex">
|
||||
<TypographyWrapper
|
||||
alignItems="center"
|
||||
color={active ? TYPOGRAPHY_COLOR.ACTIVE : TYPOGRAPHY_COLOR.DEFAULT}
|
||||
data-role={ROLE.ITEM}
|
||||
data-testid={active ? 'active-item' : 'item'}
|
||||
display="inline-flex"
|
||||
flexShrink={0}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{isBackItem && (
|
||||
<Wrapper {...styledIconProps} data-testid="back-item" pr={'breadcrumbs.divider.right'}>
|
||||
<LongLeftIcon size={'XS'} />
|
||||
</Wrapper>
|
||||
)}
|
||||
<Text.P3>{item.text}</Text.P3>
|
||||
</TypographyWrapper>
|
||||
{!isBackItem && <Divider />}
|
||||
</Wrapper>
|
||||
));
|
||||
|
||||
BreadcrumbsItem.displayName = 'BreadcrumbsItem';
|
||||
|
||||
export default BreadcrumbsItem;
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Wrapper } from '@msb/fractal-ui-styling';
|
||||
import ContextMenu from '../../context-menu';
|
||||
import type { CollapsedProps } from '../types';
|
||||
import Divider from './divider';
|
||||
|
||||
/**
|
||||
* Компонент отображает иконку More и Dropdown со списком промежуточных шагов компонента Breadcrumbs.
|
||||
*/
|
||||
const CollapsedItems = React.forwardRef<HTMLDivElement, CollapsedProps>(({ items }, ref) => (
|
||||
<Wrapper ref={ref} alignItems="center" data-testid="button-more" display="flex">
|
||||
<ContextMenu dataName="breadcrumbs" items={items} size={'S'} variant="ghost" />
|
||||
<Divider />
|
||||
</Wrapper>
|
||||
));
|
||||
|
||||
CollapsedItems.displayName = 'CollapsedItems';
|
||||
|
||||
export default CollapsedItems;
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { RightIcon } from '@fractal-ui/library';
|
||||
import { Wrapper } from '@msb/fractal-ui-styling';
|
||||
import { styledIconProps } from '../styled';
|
||||
|
||||
const Divider: React.FC = () => (
|
||||
<Wrapper {...styledIconProps} color="text.secondary" pl={'breadcrumbs.divider.left'} pr={'breadcrumbs.divider.right'}>
|
||||
<RightIcon size={'XS'} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
Divider.displayName = 'Divider';
|
||||
|
||||
export default Divider;
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Цвет текста компонента Breadcrumbs.
|
||||
*/
|
||||
export const TYPOGRAPHY_COLOR = {
|
||||
DEFAULT: 'text.secondary',
|
||||
HOVER: 'text.primary',
|
||||
ACTIVE: 'text.primary',
|
||||
} as const;
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import React, { useState, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { KEY, ROLE, useFontsReady, useResize } from '@msb/fractal-ui-core';
|
||||
import { LongLeftIcon } from '@fractal-ui/library';
|
||||
import { BreakPoint, Responsive, Wrapper } from '@msb/fractal-ui-styling';
|
||||
import BreadcrumbsItem from './components/breadcrumbs-item';
|
||||
import CollapsedItems from './components/collapsed-items';
|
||||
import { styledIconProps } from './styled';
|
||||
import type { BreadcrumbItem, BreadcrumbsProps } from './types';
|
||||
|
||||
/**
|
||||
* Breadcrumbs.
|
||||
* Хлебные крошки показывают пройденный пользователем путь и текущий раздел, в котором находится пользователь.
|
||||
*/
|
||||
const Breadcrumbs: React.FC<BreadcrumbsProps> = ({ items = [], ...props }) => {
|
||||
// Признак активности элемента
|
||||
const [activeIndex, setActiveIndex] = useState<number | undefined>();
|
||||
// Реф контейнера.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Реф последнего элемента.
|
||||
const lastItemRef = useRef<HTMLDivElement | null>(null);
|
||||
// Реф кнопки Ещё.
|
||||
const moreRef = useRef<HTMLDivElement | null>(null);
|
||||
// Последний элемент хлебных крошек.
|
||||
const lastItem: BreadcrumbItem = items[items.length - 1];
|
||||
|
||||
// Видимые элементы.
|
||||
const [visibleItems, setVisibleItems] = useState<BreadcrumbItem[]>(items);
|
||||
// Скрытые элементы.
|
||||
const [hiddenItems, setHiddenItems] = useState<BreadcrumbItem[]>([]);
|
||||
// Флаг, что пора запускать пересчет размера компонента.
|
||||
const [recalculationRender, setRecalculationRender] = useState<boolean>(false);
|
||||
|
||||
const calculateVisibleItems = useCallback(() => {
|
||||
const tempVisibleItems: BreadcrumbItem[] = [];
|
||||
const tempHiddenItems: BreadcrumbItem[] = [];
|
||||
|
||||
// Получаем ширину контейнера.
|
||||
const containerWidth = containerRef.current!.offsetWidth;
|
||||
|
||||
// Получаем дочерние элементы контейнера.
|
||||
const containerChild = Array.from(containerRef.current!.children) as HTMLElement[];
|
||||
|
||||
// Получаем ширину чилдренов в контейнере.
|
||||
const containerChildrenWidth = containerChild.reduce((acc: number, child: HTMLElement) => acc + child.offsetWidth, 0);
|
||||
|
||||
// Получаем ширину последнего элемента.
|
||||
const lastItemWidth = lastItemRef.current?.offsetWidth || 0;
|
||||
|
||||
// Получаем ширину кнопки Ещё.
|
||||
const moreRefWidth = moreRef.current?.offsetWidth || 0;
|
||||
|
||||
// Проверяем вмещаются ли все элементы в контейнер.
|
||||
// Учитываем что среди чилдренов есть кнопка Ещё
|
||||
if (containerWidth < containerChildrenWidth - moreRefWidth) {
|
||||
let stopWidth = 0;
|
||||
|
||||
// Удаляем из массива чилдренов 2 последних элемента.
|
||||
// Цикл по чилдренам не должен включать кнопку Ещё и последний элемент хлебных крошек (он должен отображаться всегда).
|
||||
containerChild.splice(-2);
|
||||
|
||||
containerChild.forEach((item: HTMLElement, index: number) => {
|
||||
stopWidth += item.offsetWidth;
|
||||
|
||||
// Ширину контейнера уменьшаем на размер кнопки Ещё и последнего элемента хлебных крошек
|
||||
// и проверяем влезает ли очередной элемент в полученный размер.
|
||||
if (containerWidth - lastItemWidth - moreRefWidth >= stopWidth) {
|
||||
tempVisibleItems.push(items[index]);
|
||||
} else {
|
||||
tempHiddenItems.push(items[index]);
|
||||
}
|
||||
});
|
||||
|
||||
// Необходимо добавить в массив видимых элементов последний элемент хлебных крошек,
|
||||
// поскольку он всегда видимый и не учавствовал в расчете.
|
||||
tempVisibleItems.push(lastItem);
|
||||
|
||||
return {
|
||||
visible: tempVisibleItems,
|
||||
hidden: tempHiddenItems,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
visible: items,
|
||||
hidden: [],
|
||||
};
|
||||
}, [items, lastItem]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (recalculationRender && containerRef.current) {
|
||||
const { visible, hidden } = calculateVisibleItems();
|
||||
|
||||
setVisibleItems(visible);
|
||||
setHiddenItems(hidden);
|
||||
setRecalculationRender(false);
|
||||
}
|
||||
}, [calculateVisibleItems, recalculationRender, items]);
|
||||
|
||||
const runRecalculate = useCallback(() => {
|
||||
setVisibleItems(items);
|
||||
setHiddenItems([]);
|
||||
setRecalculationRender(true);
|
||||
}, [items]);
|
||||
|
||||
// Триггер на ресайз окна.
|
||||
useResize({ container: containerRef.current, handler: runRecalculate });
|
||||
|
||||
useFontsReady(runRecalculate);
|
||||
|
||||
const handleBlur = () => setActiveIndex(undefined);
|
||||
|
||||
// TODO навигация с клавиатуры по хлебным крошкам реализована только для видимых элементов
|
||||
// Необходимо доработать с учетом схлопывания элементов в кнопку Ещё.
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
const isActiveSet = activeIndex !== undefined;
|
||||
|
||||
switch (event.key) {
|
||||
case KEY.ARROW_LEFT:
|
||||
setActiveIndex(prev => (isActiveSet && visibleItems[prev! - 1] ? prev! - 1 : items.length - 1));
|
||||
break;
|
||||
case KEY.ARROW_RIGHT:
|
||||
setActiveIndex(prev => (isActiveSet && visibleItems[prev! + 1] ? prev! + 1 : 0));
|
||||
break;
|
||||
case KEY.ENTER: {
|
||||
if (!isActiveSet) return;
|
||||
|
||||
const activeItem = visibleItems[activeIndex];
|
||||
|
||||
activeItem?.onClick();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const itemsList = visibleItems.map((item, index) => <BreadcrumbsItem key={item.text} active={index === activeIndex} item={item} />);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
alignItems="center"
|
||||
as={'nav'}
|
||||
boxSizing="border-box"
|
||||
display="flex"
|
||||
flexWrap="nowrap"
|
||||
{...props}
|
||||
ref={containerRef}
|
||||
aria-label="Дополнительная"
|
||||
data-role={ROLE.BREADCRUMBS}
|
||||
data-testid="breadcrumbs"
|
||||
tabIndex={1}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Responsive>
|
||||
<BreakPoint>
|
||||
{items.length === 1 ? (
|
||||
<BreadcrumbsItem isBackItem active={activeIndex === 0} item={items[0]} />
|
||||
) : (
|
||||
<>
|
||||
{itemsList.slice(0, -1)}
|
||||
{(recalculationRender || hiddenItems.length > 0) && <CollapsedItems ref={moreRef} items={hiddenItems} />}
|
||||
<div ref={lastItemRef}>{itemsList.slice(-1)}</div>
|
||||
</>
|
||||
)}
|
||||
</BreakPoint>
|
||||
<BreakPoint at="XS">
|
||||
<Wrapper
|
||||
{...styledIconProps}
|
||||
color="text.secondary"
|
||||
data-testid="mobile-breadcrumbs"
|
||||
p={'breadcrumbs.mobile'}
|
||||
onClick={lastItem.onClick}
|
||||
>
|
||||
<LongLeftIcon size={'L'} />
|
||||
</Wrapper>
|
||||
</BreakPoint>
|
||||
</Responsive>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
Breadcrumbs.displayName = 'Breadcrumbs';
|
||||
|
||||
export default Breadcrumbs;
|
||||
@@ -0,0 +1,24 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Wrapper } from '@msb/fractal-ui-styling';
|
||||
import styledCss from '@styled-system/css';
|
||||
import { TYPOGRAPHY_COLOR } from './constants';
|
||||
|
||||
export const styledIconProps = {
|
||||
display: 'inline-flex',
|
||||
flexShrink: 0,
|
||||
role: 'icon',
|
||||
};
|
||||
|
||||
/**
|
||||
* Компонент одного раздела.
|
||||
*/
|
||||
export const TypographyWrapper = styled(Wrapper)(
|
||||
styledCss({
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'pointer',
|
||||
transition: 'color .3s',
|
||||
'&:hover': {
|
||||
color: TYPOGRAPHY_COLOR.HOVER,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Хлебная крошка.
|
||||
*/
|
||||
export interface BreadcrumbItem {
|
||||
/**
|
||||
* Хендлер нажатия на раздел.
|
||||
*/
|
||||
onClick(): void;
|
||||
/**
|
||||
* Лейбл раздела.
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент Breadcrumbs.
|
||||
*/
|
||||
export interface BreadcrumbsProps {
|
||||
/**
|
||||
* Хлебные крошки.
|
||||
*/
|
||||
items: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент раздела Breadcrumbs.
|
||||
*/
|
||||
export interface BreadcrumbsItemProps {
|
||||
/**
|
||||
* Хлебная крошка.
|
||||
*/
|
||||
item: BreadcrumbItem;
|
||||
/**
|
||||
* Признак, что элемент отображаается как "Назад".
|
||||
*
|
||||
* @default false
|
||||
*
|
||||
* Если true, отображается с иконкой-стрелкой (назад) слева.
|
||||
*/
|
||||
isBackItem?: boolean;
|
||||
/**
|
||||
* Признак того, что элемент являяется активным.
|
||||
*
|
||||
* @default false
|
||||
*
|
||||
* Если true, элементы выделяется как активный.
|
||||
*/
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Свойства компонента CollapsedItems, отображающегося при сворачивании промежуточных шагов.
|
||||
*/
|
||||
export interface CollapsedProps {
|
||||
/**
|
||||
* Хлебные крошки.
|
||||
*/
|
||||
items: BreadcrumbItem[];
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import React from 'react';
|
||||
import { Title, Controls, Description, Source, Heading, Canvas, Subheading } from '@storybook/blocks';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { StoryCode, StoryLi } from 'common';
|
||||
import Calendar from '..';
|
||||
import {
|
||||
CalendarStory,
|
||||
CalendarStoryWithPeriod,
|
||||
CalendarStoryWithInitialDate,
|
||||
CalendarStoryWithInitialDateAndPeriod,
|
||||
CalendarStoryWithDisabledDates,
|
||||
CalendarStoryWithMaxDate,
|
||||
CalendarStoryWithMinDate,
|
||||
CalendarStoryWithMinAndMaxDate,
|
||||
} from './examples';
|
||||
|
||||
export {
|
||||
CalendarStory,
|
||||
CalendarStoryWithPeriod,
|
||||
CalendarStoryWithInitialDate,
|
||||
CalendarStoryWithInitialDateAndPeriod,
|
||||
CalendarStoryWithDisabledDates,
|
||||
CalendarStoryWithMaxDate,
|
||||
CalendarStoryWithMinDate,
|
||||
CalendarStoryWithMinAndMaxDate,
|
||||
};
|
||||
CalendarStory.storyName = 'Обычный календарь';
|
||||
CalendarStoryWithPeriod.storyName = 'Календарь с выбором диапазона';
|
||||
CalendarStoryWithInitialDate.storyName = 'Календарь с заранее выбранной датой';
|
||||
CalendarStoryWithInitialDateAndPeriod.storyName = 'Календарь с заранее выбранным диапазоном дат';
|
||||
CalendarStoryWithDisabledDates.storyName = 'Календарь с недоступными датами';
|
||||
CalendarStoryWithMaxDate.storyName = 'Календарь с лимитом максимальной даты';
|
||||
CalendarStoryWithMinDate.storyName = 'Календарь с лимитом минимальной даты';
|
||||
CalendarStoryWithMinAndMaxDate.storyName = 'Календарь с лимитом минимальной и максимальной даты';
|
||||
|
||||
const Docs: React.VFC = () => (
|
||||
<>
|
||||
<Title>Календарь</Title>
|
||||
<Description of={Calendar} />
|
||||
<Source
|
||||
code={`
|
||||
// импорт компонента Calendar
|
||||
import { Calendar } from '@msb/fractal-ui-composites';
|
||||
<Calendar />
|
||||
`}
|
||||
language="tsx"
|
||||
/>
|
||||
|
||||
<Heading>Виды календарей</Heading>
|
||||
<Canvas of={CalendarStory} />
|
||||
<Canvas of={CalendarStoryWithPeriod} />
|
||||
|
||||
<Heading>Инициализация выбранной даты</Heading>
|
||||
<p>
|
||||
С помощью свойства <StoryCode>date</StoryCode> можно задать начальную выбранную дату
|
||||
</p>
|
||||
<Canvas of={CalendarStoryWithInitialDate} />
|
||||
<Canvas of={CalendarStoryWithInitialDateAndPeriod} />
|
||||
|
||||
<Heading>Ограничения выбора дат</Heading>
|
||||
<p>
|
||||
С помощью свойства <StoryCode>disabledDays</StoryCode>, <StoryCode>maxDate</StoryCode> и <StoryCode>minDate</StoryCode> можно
|
||||
управлять ограничением выбора дат
|
||||
</p>
|
||||
<Canvas of={CalendarStoryWithDisabledDates} />
|
||||
<Canvas of={CalendarStoryWithMaxDate} />
|
||||
<Canvas of={CalendarStoryWithMinDate} />
|
||||
<Canvas of={CalendarStoryWithMinAndMaxDate} />
|
||||
|
||||
<Heading>Автотесты</Heading>
|
||||
|
||||
<Subheading>[data-action]</Subheading>
|
||||
<p>
|
||||
<ol>
|
||||
<StoryLi>
|
||||
Выбор месяца - <StoryCode>monthselect</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Выбор года - <StoryCode>yearselect</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Кнопка перехода к предыдущему месяцу - <StoryCode>up</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Кнопка перехода к следующему месяцу - <StoryCode>down</StoryCode>
|
||||
</StoryLi>
|
||||
</ol>
|
||||
</p>
|
||||
|
||||
<Subheading>[data-role]</Subheading>
|
||||
<p>
|
||||
<ol>
|
||||
<StoryLi>
|
||||
Календарь - <StoryCode>CALENDAR</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Выбор месяца - <StoryCode>button</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Выбор года - <StoryCode>button</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Кнопка перехода к предыдущему месяцу - <StoryCode>ICON</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Кнопка перехода к следующему месяцу - <StoryCode>ICON</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Выбор месяца /года/дня из списка - <StoryCode>item</StoryCode>
|
||||
</StoryLi>
|
||||
</ol>
|
||||
</p>
|
||||
|
||||
<Subheading>[data-testid]</Subheading>
|
||||
<p>Значения атрибутов:</p>
|
||||
<p>
|
||||
<ol>
|
||||
<StoryLi>
|
||||
Календарь - <StoryCode>calendar</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Кнопка выбора месяца - <StoryCode>calendarHeaderMonthButton</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Кнопка выбора года - <StoryCode>calendarHeaderYearButton</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Содержимое кнопки выбора месяца - <StoryCode>calendarHeaderMonthLabel</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Содержимое кнопки выбора года - <StoryCode>calendarHeaderYearLabel</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Кнопка переключения месяца на +1 - <StoryCode>calendarNextMonthChevron</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Кнопка переключения месяца на -1 - <StoryCode>calendarPrevMonthChevron</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Окно с выбором месяца - <StoryCode>calendarMonthSelect</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Окно с выбором года - <StoryCode>calendarYearSelect</StoryCode>
|
||||
</StoryLi>
|
||||
</ol>
|
||||
</p>
|
||||
|
||||
<Subheading>[data-name]</Subheading>
|
||||
<p>Значения атрибутов:</p>
|
||||
<p>
|
||||
<ol>
|
||||
<StoryLi>
|
||||
Название календаря - присваивается переданное свойство <StoryCode>datePickerName</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Кнопка выбора месяца - <StoryCode>calendar-header-month-button</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Кнопка выбора года - <StoryCode>calendar-header-year-button</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Кнопка переключения месяца на +1 - <StoryCode>calendar-next-month-chevron</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Кнопка переключения месяца на -1 - <StoryCode>calendar-prev-month-chevron</StoryCode>
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Текущий день/месяц/год календаря - <StoryCode>current</StoryCode>
|
||||
</StoryLi>
|
||||
</ol>
|
||||
</p>
|
||||
|
||||
<Subheading>[data-selected]</Subheading>
|
||||
<p>Значения атрибутов:</p>
|
||||
<p>
|
||||
<ol>
|
||||
<StoryLi>
|
||||
День - <StoryCode>true</StoryCode>, если день является частью диапазона
|
||||
</StoryLi>
|
||||
<StoryLi>
|
||||
Год / месяц в заголовке - <StoryCode>true</StoryCode>, если текущий год / месяц отображается в календаре
|
||||
</StoryLi>
|
||||
</ol>
|
||||
</p>
|
||||
|
||||
<Subheading>[data-active]</Subheading>
|
||||
<p>
|
||||
День имеет атрибут <StoryCode>[data-active]</StoryCode>, который равен <StoryCode>true</StoryCode>, если был выбран этот день
|
||||
</p>
|
||||
|
||||
<Subheading>[data-disabled]</Subheading>
|
||||
<p>
|
||||
День имеет атрибут <StoryCode>[data-disabled]</StoryCode>, который равен <StoryCode>true</StoryCode>, если день недоступен для выбора
|
||||
</p>
|
||||
|
||||
<Subheading>[data-current]</Subheading>
|
||||
<p>
|
||||
День имеет атрибут <StoryCode>[data-current]</StoryCode>, который равен <StoryCode>true</StoryCode>, если этот день равен сегодняшнему
|
||||
</p>
|
||||
|
||||
<Subheading>[data-date]</Subheading>
|
||||
<p>
|
||||
День имеет атрибут <StoryCode>[data-date]</StoryCode>, который равен номеру дня
|
||||
</p>
|
||||
|
||||
<Subheading>[data-part-of-range]</Subheading>
|
||||
<p>
|
||||
День имеет атрибут <StoryCode>[data-part-of-range]</StoryCode>, который равен <StoryCode>middle</StoryCode>, если день является частью
|
||||
диапазона и находится между началом и концом диапазона, равен <StoryCode>start</StoryCode>, если это начало дипазаона, и -{' '}
|
||||
<StoryCode>end</StoryCode>, если конец диапазона
|
||||
</p>
|
||||
|
||||
<Heading>Песочница</Heading>
|
||||
<Canvas of={CalendarStory} />
|
||||
|
||||
<Heading>API</Heading>
|
||||
<Controls of={CalendarStory} />
|
||||
</>
|
||||
);
|
||||
|
||||
const StoryMeta: Meta = {
|
||||
title: 'Базовые/Calendar',
|
||||
component: Calendar,
|
||||
parameters: {
|
||||
docs: {
|
||||
page: Docs,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default StoryMeta;
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { FC } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@msb/fractal-ui-core';
|
||||
import { BottomSheet } from '@msb/fractal-ui-overlays';
|
||||
import { BreakPoint, Responsive } from '@msb/fractal-ui-styling';
|
||||
import type { StoryFn } from '@storybook/react';
|
||||
import dayjs from 'dayjs';
|
||||
import Calendar from '..';
|
||||
import type { CalendarProps } from '../types';
|
||||
|
||||
const dateFormat = 'YYYY-MM-DD';
|
||||
const date1 = dayjs().add(2, 'day').startOf('day');
|
||||
const date2 = dayjs().add(7, 'day').startOf('day');
|
||||
const date3 = dayjs().add(1, 'month').startOf('day');
|
||||
|
||||
const CalendarCommonStory: FC<{ title: string; calendarProps: CalendarProps }> = ({ title, calendarProps }) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ maxWidth: '300px' }}>{title}</p>
|
||||
<Responsive>
|
||||
<BreakPoint>
|
||||
<Calendar {...calendarProps} />
|
||||
</BreakPoint>
|
||||
<BreakPoint at="XS">
|
||||
<Button dataAction="open-calendar" onClick={() => setIsOpen(true)}>
|
||||
Открыть календарь
|
||||
</Button>
|
||||
<BottomSheet header={title} isNeedScroll={false} isOpen={isOpen} onClose={() => setIsOpen(false)}>
|
||||
<Calendar {...calendarProps} isMobile />
|
||||
</BottomSheet>
|
||||
</BreakPoint>
|
||||
</Responsive>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CalendarStory: StoryFn<CalendarProps> = props => (
|
||||
<CalendarCommonStory calendarProps={props} title="Календарь с возможностью выбора одной даты" />
|
||||
);
|
||||
|
||||
export const CalendarStoryWithPeriod: StoryFn<CalendarProps> = props => (
|
||||
<CalendarCommonStory calendarProps={{ isPeriod: true, ...props }} title="Календарь с выбором диапазона" />
|
||||
);
|
||||
|
||||
export const CalendarStoryWithInitialDate: StoryFn<CalendarProps> = props => (
|
||||
<CalendarCommonStory
|
||||
calendarProps={{ value: date1.toDate(), ...props }}
|
||||
title={`Календарь с заранее выбранной датой - ${date1.format(dateFormat)}`}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CalendarStoryWithInitialDateAndPeriod: StoryFn<CalendarProps> = props => (
|
||||
<CalendarCommonStory
|
||||
calendarProps={{ isPeriod: true, value: [date1.toDate(), date2.toDate()], ...props }}
|
||||
title={`Календарь с заранее выбранным диапазоном дат c ${date1.format(dateFormat)} по ${date2.format(dateFormat)}`}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CalendarStoryWithDisabledDates: StoryFn<CalendarProps> = props => (
|
||||
<CalendarCommonStory
|
||||
calendarProps={{ disabledDays: [date1.format(dateFormat), date2.format(dateFormat)], ...props }}
|
||||
title={`Календарь с недоступными датами ${date1.format(dateFormat)} и ${date2.format(dateFormat)}`}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CalendarStoryWithMaxDate: StoryFn<CalendarProps> = props => (
|
||||
<CalendarCommonStory
|
||||
calendarProps={{ maxDate: date2.format(dateFormat), ...props }}
|
||||
title={`Установлена максимальная дата на ${date2.format(dateFormat)}`}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CalendarStoryWithMinDate: StoryFn<CalendarProps> = props => (
|
||||
<CalendarCommonStory
|
||||
calendarProps={{ minDate: date1.format(dateFormat), ...props }}
|
||||
title={`Установлена минимальная дата на ${date1.format(dateFormat)}`}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CalendarStoryWithMinAndMaxDate: StoryFn<CalendarProps> = props => (
|
||||
<CalendarCommonStory
|
||||
calendarProps={{ maxDate: date3.format(dateFormat), minDate: date1.format(dateFormat), ...props }}
|
||||
title={`Установлена минимальная дата на ${date1.format(dateFormat)}, а максимальная на ${date3.format(dateFormat)}`}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,208 @@
|
||||
/* eslint-disable jest/prefer-spy-on */
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { mockHTMLElementPrototype, mockObservers } from 'common';
|
||||
import dayjs from 'dayjs';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { CalendarWrapper } from './helpers';
|
||||
|
||||
const rect = { x: 0, y: 0, width: 0, height: 0, bottom: 0, left: 0, right: 0, top: 0, toJSON: () => jest.fn() };
|
||||
|
||||
const currentDate = dayjs('2022-06-01');
|
||||
const exampleDate = currentDate.toDate();
|
||||
|
||||
describe('Calendar Mobile', () => {
|
||||
beforeEach(() => {
|
||||
mockObservers();
|
||||
jest.useFakeTimers('modern').setSystemTime(currentDate.toDate());
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
mockHTMLElementPrototype();
|
||||
mockObservers();
|
||||
});
|
||||
|
||||
afterAll(() => jest.useFakeTimers('modern').setSystemTime(new Date()));
|
||||
|
||||
it('проверяет наличие контейнера', () => {
|
||||
render(<CalendarWrapper isMobile />);
|
||||
|
||||
expect(screen.getByTestId('calendar')).toBeDefined();
|
||||
});
|
||||
it('проверяет вызов addDate внутри компонента', () => {
|
||||
const { getByTestId } = render(<CalendarWrapper isMobile />);
|
||||
const daysScroller = getByTestId('daysScroller');
|
||||
const callback: (entries: IntersectionObserverEntry[]) => void = (window.IntersectionObserver as jest.Mock).mock.calls[0][0];
|
||||
const callArgs = [
|
||||
{
|
||||
isIntersecting: true,
|
||||
boundingClientRect: rect,
|
||||
intersectionRatio: 0,
|
||||
intersectionRect: rect,
|
||||
rootBounds: rect,
|
||||
target: daysScroller,
|
||||
time: 0,
|
||||
},
|
||||
];
|
||||
|
||||
expect(daysScroller.children).toHaveLength(1);
|
||||
|
||||
act(() => callback(callArgs));
|
||||
expect(daysScroller.children).toHaveLength(6);
|
||||
|
||||
const callbackFirst: (entries: IntersectionObserverEntry[]) => void = (window.IntersectionObserver as jest.Mock).mock.calls[1][0];
|
||||
|
||||
act(() => callbackFirst(callArgs));
|
||||
expect(daysScroller.children).toHaveLength(11);
|
||||
});
|
||||
it('вне зоны видимости календаря, отображается DummyDay', () => {
|
||||
const { getByTestId } = render(<CalendarWrapper isMobile />);
|
||||
const daysScroller = getByTestId('daysScroller');
|
||||
const callback: (entries: IntersectionObserverEntry[]) => void = (window.IntersectionObserver as jest.Mock).mock.calls[0][0];
|
||||
const callArgs = [
|
||||
{
|
||||
isIntersecting: false,
|
||||
boundingClientRect: rect,
|
||||
intersectionRatio: 0,
|
||||
intersectionRect: rect,
|
||||
rootBounds: rect,
|
||||
target: daysScroller,
|
||||
time: 0,
|
||||
},
|
||||
];
|
||||
|
||||
act(() => callback(callArgs));
|
||||
expect(getByTestId('calendarDummyDay')).toBeDefined();
|
||||
});
|
||||
it('при изменении даты, правильно отображается месяц / год', () => {
|
||||
const { getByTestId } = render(<CalendarWrapper isMobile />);
|
||||
|
||||
fireEvent.click(getByTestId('calendarHeaderYearButton'));
|
||||
|
||||
const selectYear = currentDate.year() + 2;
|
||||
const { getByText } = within(getByTestId('calendarYearSelect'));
|
||||
|
||||
act(() => {
|
||||
jest.useFakeTimers();
|
||||
fireEvent.click(getByText(selectYear));
|
||||
jest.runAllTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const { getByText: getByTextInYearButon } = within(getByTestId('calendarHeaderYearLabel'));
|
||||
|
||||
expect(getByTextInYearButon(selectYear).dataset.selected).toBe('true');
|
||||
});
|
||||
it('не срабатывает onHover для мобильных устройств', () => {
|
||||
const onHover = jest.fn();
|
||||
const { getByTestId } = render(<CalendarWrapper isMobile isPeriod onHover={onHover} />);
|
||||
|
||||
const { getByText } = within(getByTestId('daysScroller'));
|
||||
|
||||
fireEvent.mouseOver(getByText(17));
|
||||
|
||||
expect(onHover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('при выборе месяца, его название отображается в хедере', () => {
|
||||
const { getByTestId } = render(<CalendarWrapper isMobile value={exampleDate} />);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(getByTestId('calendarHeaderMonthButton'));
|
||||
|
||||
const { getByText } = within(getByTestId('calendarMonthSelect'));
|
||||
|
||||
jest.useFakeTimers();
|
||||
fireEvent.click(getByText('Авг'));
|
||||
|
||||
jest.runAllTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
expect(getByTestId('calendarHeaderMonthLabel').children[1]).toHaveTextContent('Август');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(getByTestId('calendarHeaderMonthButton'));
|
||||
|
||||
const { getByText } = within(getByTestId('calendarMonthSelect'));
|
||||
|
||||
jest.useFakeTimers();
|
||||
fireEvent.click(getByText('Дек'));
|
||||
|
||||
jest.runAllTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
expect(getByTestId('calendarHeaderMonthLabel').children[1]).toHaveTextContent('Декабрь');
|
||||
});
|
||||
|
||||
it('при выборе месяца, меняются отображаемые даты', () => {
|
||||
const { getByTestId } = render(<CalendarWrapper isMobile value={exampleDate} />);
|
||||
const daysScroller = getByTestId('daysScroller');
|
||||
const callback: (entries: IntersectionObserverEntry[]) => void = (window.IntersectionObserver as jest.Mock).mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
callback([
|
||||
{
|
||||
isIntersecting: true,
|
||||
boundingClientRect: rect,
|
||||
intersectionRatio: 0,
|
||||
intersectionRect: rect,
|
||||
rootBounds: rect,
|
||||
target: daysScroller,
|
||||
time: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
expect(daysScroller.children[0].children[1]).toHaveTextContent('Июль');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(getByTestId('calendarHeaderMonthButton'));
|
||||
|
||||
const { getByText } = within(getByTestId('calendarMonthSelect'));
|
||||
|
||||
jest.useFakeTimers();
|
||||
fireEvent.click(getByText('Сен'));
|
||||
|
||||
jest.runAllTimers();
|
||||
jest.useRealTimers();
|
||||
callback([
|
||||
{
|
||||
isIntersecting: true,
|
||||
boundingClientRect: rect,
|
||||
intersectionRatio: 0,
|
||||
intersectionRect: rect,
|
||||
rootBounds: rect,
|
||||
target: daysScroller,
|
||||
time: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
expect(getByTestId('daysScroller').children[0].children[1]).toHaveTextContent('Октябрь');
|
||||
});
|
||||
|
||||
it('срабатывает onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
const { getByTestId } = render(<CalendarWrapper isMobile value={exampleDate} onChange={onChange} />);
|
||||
|
||||
const selectDay = 17;
|
||||
const { getByText } = within(getByTestId('daysScroller'));
|
||||
|
||||
fireEvent.click(getByText(selectDay));
|
||||
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
it('срабатывает onChange для диапазона', () => {
|
||||
const onChange = jest.fn();
|
||||
const { getByTestId } = render(<CalendarWrapper isMobile isPeriod value={exampleDate} onChange={onChange} />);
|
||||
|
||||
const toDay = 17;
|
||||
const { getByText } = within(getByTestId('daysScroller'));
|
||||
|
||||
fireEvent.click(getByText(toDay));
|
||||
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import dayjs from 'dayjs';
|
||||
import { CalendarWrapper } from './helpers';
|
||||
|
||||
const dateFormat = 'YYYY-MM-DD';
|
||||
const exampleDay = dayjs().set('date', 15);
|
||||
const exampleDay1 = exampleDay.add(1, 'day');
|
||||
const exampleDay2 = exampleDay.add(5, 'day');
|
||||
const exampleDay3 = exampleDay.add(3, 'day');
|
||||
const currentDay = dayjs();
|
||||
|
||||
describe('Calendar Autotest', () => {
|
||||
const calendarQuery = '[data-role="calendar"]';
|
||||
const activeDayQuery = '[data-active="true"]';
|
||||
const currentDayQuery = '[data-current="true"]';
|
||||
const disableDayQuery = '[data-disabled="true"]';
|
||||
const calendarByDatePickerNameQuery = '[data-name="testName"]';
|
||||
const calendarHeaderMonthButtonQuery =
|
||||
'[data-name="calendar-header-month-button"][data-role="button"][role="button"][data-action="monthselect"]';
|
||||
const calendarHeaderYearButtonQuery =
|
||||
'[data-name="calendar-header-year-button"][data-role="button"][role="button"][data-action="yearselect"]';
|
||||
const calendarPrevMonthChevronQuery = '[data-name="calendar-prev-month-chevron"][role="icon"][data-action="up"]';
|
||||
const calendarNextMonthChevronQuery = '[data-name="calendar-next-month-chevron"][role="icon"][data-action="down"]';
|
||||
const exampleDay3Query = `[data-role="item"][data-date="${exampleDay3.format(dateFormat)}"][data-part-of-range="middle"]`;
|
||||
const calendarMonthAndYearQuery = `[data-month="${currentDay.month()}"][data-year="${currentDay.year()}"]`;
|
||||
const januaryQuery = `[data-month="1"][role="item"]`;
|
||||
const fromDateQuery = '[data-name="from"]';
|
||||
const toDateQuery = '[data-name="to"]';
|
||||
|
||||
it('календарь имеет атрибут [data-name="testName"] при установке datePickerName в "testName"', async () => {
|
||||
const { container } = render(<CalendarWrapper datePickerName="testName" />);
|
||||
|
||||
await waitFor(() => expect(container.querySelector(calendarByDatePickerNameQuery)).not.toBeNull());
|
||||
});
|
||||
it('календарь имеет атрибут [data-role="calendar"]', async () => {
|
||||
const { container } = render(<CalendarWrapper />);
|
||||
|
||||
await waitFor(() => expect(container.querySelector(calendarQuery)).not.toBeNull());
|
||||
});
|
||||
it('день календаря имеет атрибут data-active', async () => {
|
||||
const { container } = render(<CalendarWrapper value={exampleDay.toDate()} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelector(activeDayQuery)).not.toBeNull());
|
||||
});
|
||||
it('текущий день в календаре имеет атрибут [data-current="true"]', async () => {
|
||||
const { container } = render(<CalendarWrapper />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(currentDayQuery)).toHaveLength(1));
|
||||
});
|
||||
it('кнопка выбора месяца имеет атрибут [data-name="calendar-header-month-button"]', async () => {
|
||||
const { container } = render(<CalendarWrapper />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(calendarHeaderMonthButtonQuery)).toHaveLength(1));
|
||||
});
|
||||
it('кнопка выбора года имеет атрибут [data-name="calendar-header-year-button"]', async () => {
|
||||
const { container } = render(<CalendarWrapper />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(calendarHeaderYearButtonQuery)).toHaveLength(1));
|
||||
});
|
||||
it('кнопка вверх имеет атрибут [data-name="calendar-prev-month-chevron"]', async () => {
|
||||
const { container } = render(<CalendarWrapper />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(calendarPrevMonthChevronQuery)).toHaveLength(1));
|
||||
});
|
||||
it('кнопка вниз имеет атрибут [data-name="calendar-next-month-chevron"]', async () => {
|
||||
const { container } = render(<CalendarWrapper />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(calendarNextMonthChevronQuery)).toHaveLength(1));
|
||||
});
|
||||
it('недоступный день exampleDay имеет атрибут [data-disabled="true"]', async () => {
|
||||
const { container } = render(<CalendarWrapper disabledDays={[exampleDay1.format(dateFormat)]} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(disableDayQuery)).toHaveLength(1));
|
||||
});
|
||||
it('день между выбранным диапазоном дат имеет атрибут [data-part-of-range="middle"]', async () => {
|
||||
const { container } = render(<CalendarWrapper isPeriod value={[exampleDay1.toDate(), exampleDay2.toDate()]} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(exampleDay3Query)).toHaveLength(1));
|
||||
});
|
||||
it('стартовый и конечный дни диапазона имеет атрибуты [data-name="from"] и [data-name="to"]', async () => {
|
||||
const { container } = render(<CalendarWrapper isPeriod value={[exampleDay1.toDate(), exampleDay2.toDate()]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll(fromDateQuery)).toHaveLength(1);
|
||||
expect(container.querySelectorAll(toDateQuery)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
it('календарь имеет атрибуты [data-month][data-year] равные текущему отображаемому году и месяцу', async () => {
|
||||
const { container } = render(<CalendarWrapper value={currentDay.toDate()} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelector(calendarMonthAndYearQuery)).not.toBeNull());
|
||||
});
|
||||
it('у кнопки выбора января месяца присутствуют атрибуты [data-month="1"][role="button"]', async () => {
|
||||
const { container } = render(<CalendarWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(januaryQuery)?.textContent).toBe('Янв');
|
||||
expect(container.querySelectorAll(januaryQuery)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, within, act, waitFor } from '@testing-library/react';
|
||||
import { mockHTMLElementPrototype } from 'common';
|
||||
import dayjs from 'dayjs';
|
||||
import { CalendarWrapper } from './helpers';
|
||||
|
||||
/** Вспомогательная функция для выбора элемента. */
|
||||
const at = (arr: any[], percent: number) => arr[Math.floor(((percent * (arr.length - 1)) / (arr.length - 1)) * (arr.length - 1))];
|
||||
const currentDate = dayjs();
|
||||
const exampleDate = dayjs('2022-06-01').toDate();
|
||||
|
||||
describe('Calendar', () => {
|
||||
beforeAll(() => {
|
||||
mockHTMLElementPrototype();
|
||||
jest.useFakeTimers('modern').setSystemTime(exampleDate);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useFakeTimers('modern').setSystemTime(new Date());
|
||||
});
|
||||
|
||||
it('проверяет наличие контейнера', async () => {
|
||||
render(<CalendarWrapper />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('calendar')).toBeDefined());
|
||||
});
|
||||
|
||||
/** При выборе первого пустого элемента (percent = 0) или последнего (percent = 1) не должен срабатывать onChange. */
|
||||
it.each`
|
||||
percent | expectedCallTimes
|
||||
${0} | ${0}
|
||||
${0.5} | ${1}
|
||||
${1} | ${0}
|
||||
`('срабатывает onChange $expectedCallTimes раз', async ({ percent, expectedCallTimes }) => {
|
||||
const changeHandler = jest.fn();
|
||||
|
||||
render(<CalendarWrapper value={exampleDate} onChange={changeHandler} />);
|
||||
|
||||
const days = screen.getAllByTestId('calendarDaysContainer')[0].children;
|
||||
|
||||
fireEvent.click(at(Array.from(days), percent));
|
||||
|
||||
await waitFor(() => expect(changeHandler).toHaveBeenCalledTimes(expectedCallTimes));
|
||||
});
|
||||
|
||||
it.each`
|
||||
el1 | expectedCallTimes
|
||||
${0} | ${1}
|
||||
${0.5} | ${2}
|
||||
${1} | ${1}
|
||||
`('срабатывает onChange $expectedCallTimes раз (isPeriod = true)', async ({ el1, expectedCallTimes }) => {
|
||||
const changeHandler = jest.fn();
|
||||
|
||||
render(<CalendarWrapper isPeriod value={exampleDate} onChange={changeHandler} />);
|
||||
|
||||
const days = screen.getAllByTestId('calendarDaysContainer')[0].children;
|
||||
|
||||
fireEvent.click(at(Array.from(days), el1));
|
||||
fireEvent.click(at(Array.from(days), 0.7));
|
||||
|
||||
await waitFor(() => expect(changeHandler).toHaveBeenCalledTimes(expectedCallTimes));
|
||||
});
|
||||
it('срабатывает выбор месяца', async () => {
|
||||
render(<CalendarWrapper />);
|
||||
|
||||
const matchMonth = within(screen.getByTestId('calendarMonthSelect')).getByText('Сен');
|
||||
|
||||
act(() => {
|
||||
jest.useFakeTimers();
|
||||
fireEvent.click(screen.getByTestId('calendarHeaderMonthLabel'));
|
||||
jest.runAllTimers();
|
||||
fireEvent.click(matchMonth);
|
||||
jest.runAllTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const { getByText: getByTextInMonthButon } = within(screen.getByTestId('calendarHeaderMonthLabel'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTextInMonthButon('Октябрь').dataset.selected).toBe('false');
|
||||
expect(getByTextInMonthButon('Сентябрь').dataset.selected).toBe('true');
|
||||
});
|
||||
});
|
||||
it('срабатывает выбор года', async () => {
|
||||
const selectYear = currentDate.year() - 1;
|
||||
|
||||
render(<CalendarWrapper />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('calendarHeaderYearLabel'));
|
||||
|
||||
const matchYear = within(screen.getByTestId('calendarYearSelect')).getByText(selectYear);
|
||||
|
||||
act(() => {
|
||||
jest.useFakeTimers();
|
||||
fireEvent.click(matchYear);
|
||||
jest.runAllTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const { getByText: getByTextInYearButon } = within(screen.getByTestId('calendarHeaderYearLabel'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTextInYearButon(selectYear).dataset.selected).toBe('true');
|
||||
expect(getByTextInYearButon(selectYear + 1).dataset.selected).toBe('false');
|
||||
});
|
||||
});
|
||||
it.each(['calendarPrevMonthChevron', 'calendarNextMonthChevron'])('срабатывает переключение месяца стрелками', async chevronTestId => {
|
||||
render(<CalendarWrapper />);
|
||||
|
||||
const monthLabelBefore = screen.getByTestId('calendarHeaderMonthLabel');
|
||||
const activeMonthBefore = monthLabelBefore.querySelector('[data-selected="true"]')?.textContent;
|
||||
|
||||
fireEvent.click(screen.getByTestId(chevronTestId));
|
||||
|
||||
const monthLabelAfter = screen.getByTestId('calendarHeaderMonthLabel');
|
||||
const activeMonthAfter = monthLabelAfter.querySelector('[data-selected="true"]')?.textContent;
|
||||
|
||||
await waitFor(() => expect(activeMonthAfter).not.toStrictEqual(activeMonthBefore));
|
||||
});
|
||||
it('выделяются элементы во время выбора диапазона', async () => {
|
||||
render(<CalendarWrapper isPeriod />);
|
||||
|
||||
fireEvent.click(screen.getByText('10'));
|
||||
fireEvent.mouseOver(screen.getByText('15'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('13').dataset.selected).toBe('true');
|
||||
expect(screen.getByText('17').dataset.selected).toBe('false');
|
||||
});
|
||||
});
|
||||
it('onMouseEnter вызван 1 раз', async () => {
|
||||
const onMouseEnter = jest.fn();
|
||||
|
||||
render(<CalendarWrapper onMouseEnter={onMouseEnter} />);
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId('calendar'));
|
||||
|
||||
await waitFor(() => expect(onMouseEnter).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
it('onMouseLeave вызван 1 раз', async () => {
|
||||
const onMouseLeave = jest.fn();
|
||||
|
||||
render(<CalendarWrapper onMouseLeave={onMouseLeave} />);
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId('calendar'));
|
||||
|
||||
await waitFor(() => expect(onMouseLeave).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
it('срабатывает onHover 1 раз', async () => {
|
||||
const onHover = jest.fn();
|
||||
|
||||
render(<CalendarWrapper onHover={onHover} />);
|
||||
|
||||
fireEvent.mouseOver(screen.getByText('15'));
|
||||
|
||||
await waitFor(() => expect(onHover).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
it('срабатывает onHover 2 раза при выборе диапазона', async () => {
|
||||
const onHover = jest.fn();
|
||||
|
||||
render(<CalendarWrapper isPeriod onHover={onHover} />);
|
||||
|
||||
fireEvent.mouseOver(screen.getByText('10'));
|
||||
fireEvent.click(screen.getByText('10'));
|
||||
fireEvent.mouseOver(screen.getByText('15'));
|
||||
|
||||
await waitFor(() => expect(onHover).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
it('срабатывает onHover 2 раза при выборе одного и того же дня в диапазоне', async () => {
|
||||
const onHover = jest.fn();
|
||||
|
||||
render(<CalendarWrapper isPeriod onHover={onHover} />);
|
||||
|
||||
const testSelectDay = '11';
|
||||
|
||||
fireEvent.mouseOver(screen.getByText(testSelectDay));
|
||||
fireEvent.click(screen.getByText(testSelectDay));
|
||||
fireEvent.mouseOver(screen.getByText(testSelectDay));
|
||||
|
||||
await waitFor(() => expect(onHover).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
it('при выборе активного дня спадает выбор', async () => {
|
||||
render(<CalendarWrapper />);
|
||||
|
||||
const testSelectDay = '11';
|
||||
|
||||
fireEvent.click(screen.getByText(testSelectDay));
|
||||
await waitFor(() => expect(screen.getByText(testSelectDay)).toHaveAttribute('data-active', 'true'));
|
||||
|
||||
fireEvent.click(screen.getByText(testSelectDay));
|
||||
await waitFor(() => expect(screen.getByText(testSelectDay)).toHaveAttribute('data-active', 'false'));
|
||||
});
|
||||
it('при наведение на пустые элементы до / после месяца подсвечиваются все дни вплоть до начала / конца месяца', async () => {
|
||||
jest.useFakeTimers('modern').setSystemTime(dayjs('2022-01-01').toDate());
|
||||
render(<CalendarWrapper isPeriod value={dayjs('2022-01-15').toDate()} />);
|
||||
|
||||
const allDays = Array.from(screen.getByTestId('calendarDaysContainer').children) as HTMLDivElement[];
|
||||
|
||||
const notEmptyDays = allDays.map(el => el.firstChild as HTMLDivElement).filter(el => el.dataset.date);
|
||||
const dataPartOfRangeAttr = 'data-part-of-range';
|
||||
const startPartOfRange = 'start';
|
||||
const endPartOfRange = 'end';
|
||||
|
||||
fireEvent.mouseOver(allDays[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notEmptyDays[0]).toHaveAttribute(dataPartOfRangeAttr, startPartOfRange);
|
||||
expect(notEmptyDays[notEmptyDays.length - 1]).not.toHaveAttribute(dataPartOfRangeAttr, endPartOfRange);
|
||||
});
|
||||
|
||||
fireEvent.mouseOver(allDays[allDays.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notEmptyDays[0]).not.toHaveAttribute(dataPartOfRangeAttr, startPartOfRange);
|
||||
expect(notEmptyDays[notEmptyDays.length - 1]).toHaveAttribute(dataPartOfRangeAttr, endPartOfRange);
|
||||
});
|
||||
|
||||
jest.useFakeTimers('modern').setSystemTime(new Date());
|
||||
});
|
||||
it('если при выборе диапазона вывести курсор за границы выбора дней, пропадает подсветка', async () => {
|
||||
const onHover = jest.fn();
|
||||
|
||||
render(<CalendarWrapper isPeriod onHover={onHover} />);
|
||||
|
||||
const calendarDaysContainer = screen.getAllByTestId('calendarDaysContainer')[0];
|
||||
|
||||
fireEvent.click(screen.getByText('13'));
|
||||
fireEvent.mouseOver(screen.getByText('15'));
|
||||
|
||||
await waitFor(() => expect(calendarDaysContainer.querySelector('[data-selected=true]')).not.toBeNull());
|
||||
|
||||
fireEvent.mouseOut(calendarDaysContainer);
|
||||
|
||||
await waitFor(() => expect(calendarDaysContainer.querySelector('[data-selected=true]')).toBeNull());
|
||||
});
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { getAlignedMinYear } from '../helpers';
|
||||
|
||||
describe('getAlignedMinYear', () => {
|
||||
it.each([
|
||||
[2020, 2019],
|
||||
[2021, 2019],
|
||||
[2022, 2019],
|
||||
[2023, 2022],
|
||||
[2024, 2022],
|
||||
[2025, 2022],
|
||||
[2026, 2025],
|
||||
[2027, 2025],
|
||||
])('возвращает правильный ближайший год слева', (val, expected) => {
|
||||
const res = getAlignedMinYear(val);
|
||||
|
||||
expect(res).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { fillMonth, getDayOfWeek, initializeActiveDate } from '../helpers';
|
||||
import type { RangeDate } from '../types';
|
||||
|
||||
const testCurrentDate = dayjs(`2022-06-15`);
|
||||
const testOldDate = testCurrentDate.subtract(1, 'month');
|
||||
const testFromDate = testCurrentDate.subtract(3, 'day');
|
||||
const testToDate = testCurrentDate.add(3, 'day');
|
||||
const testEmptyActiveDay: RangeDate = {};
|
||||
const testActiveDay = {
|
||||
from: testFromDate,
|
||||
to: testToDate,
|
||||
};
|
||||
|
||||
describe('getDayOfWeek', () => {
|
||||
it.each([
|
||||
[0, 6],
|
||||
[1, 0],
|
||||
[2, 1],
|
||||
[3, 2],
|
||||
[4, 3],
|
||||
[5, 4],
|
||||
[6, 5],
|
||||
])('возвращает правильный день недели', (val, expected) => {
|
||||
const res = getDayOfWeek(val);
|
||||
|
||||
expect(res).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillMonth', () => {
|
||||
it('заполнен месяц', () => {
|
||||
const { days } = fillMonth({
|
||||
showDate: testCurrentDate,
|
||||
currentDate: testCurrentDate,
|
||||
activeDay: testEmptyActiveDay,
|
||||
disabledDays: [],
|
||||
});
|
||||
|
||||
expect(days.length).toBeGreaterThanOrEqual(testCurrentDate.daysInMonth());
|
||||
});
|
||||
it('есть хотя-бы один день', () => {
|
||||
const { days } = fillMonth({
|
||||
showDate: testCurrentDate,
|
||||
currentDate: testCurrentDate,
|
||||
activeDay: testEmptyActiveDay,
|
||||
disabledDays: [],
|
||||
});
|
||||
const day = days.find(d => d.date);
|
||||
|
||||
expect(day).not.toBeNull();
|
||||
});
|
||||
it('устанавливается isCurrent, если в календаре присутствует сегодняшний день.', () => {
|
||||
const { days } = fillMonth({
|
||||
showDate: testCurrentDate,
|
||||
currentDate: testCurrentDate,
|
||||
activeDay: testEmptyActiveDay,
|
||||
disabledDays: [],
|
||||
});
|
||||
const day = days.find(d => d.isCurrent);
|
||||
|
||||
expect(day).not.toBeNull();
|
||||
expect(day?.date).toStrictEqual(testCurrentDate.date());
|
||||
});
|
||||
it('устанавливается partOfRange, при передаче range.', () => {
|
||||
const { days } = fillMonth({
|
||||
showDate: testCurrentDate,
|
||||
currentDate: testOldDate,
|
||||
activeDay: testEmptyActiveDay,
|
||||
disabledDays: [],
|
||||
range: testActiveDay,
|
||||
});
|
||||
const startDay = days.find(d => d.partOfRange === 'start');
|
||||
const middleDay = days.filter(d => d.partOfRange === 'middle');
|
||||
const endDay = days.find(d => d.partOfRange === 'end');
|
||||
|
||||
expect(startDay).toBeDefined();
|
||||
expect(endDay).toBeDefined();
|
||||
expect(middleDay).toHaveLength(5);
|
||||
});
|
||||
it.each([
|
||||
{ ...testActiveDay, to: testCurrentDate.add(1, 'month') },
|
||||
{ ...testActiveDay, from: testCurrentDate.subtract(1, 'month') },
|
||||
])('устанавливается partOfRange в пустых ячейках', range => {
|
||||
const { days } = fillMonth({
|
||||
showDate: testCurrentDate,
|
||||
currentDate: testOldDate,
|
||||
activeDay: testEmptyActiveDay,
|
||||
disabledDays: [],
|
||||
range,
|
||||
});
|
||||
const emptyDayWithPartOfRange = days.find(d => !d.date && d.partOfRange === 'middle');
|
||||
|
||||
expect(emptyDayWithPartOfRange).toBeDefined();
|
||||
});
|
||||
it.each([
|
||||
['minDate', testToDate],
|
||||
['maxDate', testFromDate],
|
||||
['disabledDays', [testToDate.format('YYYY-MM-DD')]],
|
||||
])('устанавливается disabled при передаче %s', (attr, value) => {
|
||||
const { days } = fillMonth({
|
||||
showDate: testCurrentDate,
|
||||
currentDate: testOldDate,
|
||||
activeDay: testEmptyActiveDay,
|
||||
[attr]: value,
|
||||
});
|
||||
const disabledDays = days.filter(d => d.disabled);
|
||||
|
||||
expect(disabledDays).not.toHaveLength(0);
|
||||
});
|
||||
it('устанавливается isActive', () => {
|
||||
const { days } = fillMonth({
|
||||
showDate: testCurrentDate,
|
||||
currentDate: testOldDate,
|
||||
activeDay: testActiveDay,
|
||||
});
|
||||
const activeDay = days.find(d => d.isActive);
|
||||
|
||||
expect(activeDay?.isActive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeActiveDate', () => {
|
||||
it('не передаём даты', () => {
|
||||
const res = initializeActiveDate(undefined);
|
||||
|
||||
expect(res.from).toBeUndefined();
|
||||
expect(res.to).toBeUndefined();
|
||||
});
|
||||
it('передаём одну дату', () => {
|
||||
const res = initializeActiveDate(new Date('2020-06-14'));
|
||||
|
||||
expect(res?.from?.date()).toBe(14);
|
||||
expect(res.to).toBeUndefined();
|
||||
});
|
||||
it.each<[[Date, Date], number[]]>([
|
||||
[
|
||||
[new Date('2020-06-14'), new Date('2020-06-17')],
|
||||
[14, 17],
|
||||
],
|
||||
[
|
||||
[new Date('2020-06-12'), new Date('2020-06-07')],
|
||||
[7, 12],
|
||||
],
|
||||
])('передаём две даты', (dates, [expected1, expected2]) => {
|
||||
const res = initializeActiveDate(dates);
|
||||
|
||||
expect(res?.from?.date()).toStrictEqual(expected1);
|
||||
expect(res?.to?.date()).toStrictEqual(expected2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import { lightTheme } from '@msb/fractal-ui-styling';
|
||||
import Calendar from '..';
|
||||
import type { CalendarProps } from '../types';
|
||||
|
||||
/** Обёртка с темой над компонентом. */
|
||||
export const CalendarWrapper = (props: CalendarProps) => (
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<Calendar {...props} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import Weeks from '../components/weeks';
|
||||
import { DAYS_OF_WEEK } from '../constants';
|
||||
import CalendarContext from '../context';
|
||||
import type { CalendarContextProps } from '../types';
|
||||
|
||||
const testContextValue = {
|
||||
weekDays: DAYS_OF_WEEK,
|
||||
};
|
||||
|
||||
describe('Weeks', () => {
|
||||
it('проверяет наличие контейнера', () => {
|
||||
render(
|
||||
<CalendarContext.Provider value={testContextValue as CalendarContextProps}>
|
||||
<Weeks />
|
||||
</CalendarContext.Provider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('calendarWeek')).toBeDefined();
|
||||
});
|
||||
it('число дней равно 7', () => {
|
||||
render(
|
||||
<CalendarContext.Provider value={testContextValue as CalendarContextProps}>
|
||||
<Weeks />
|
||||
</CalendarContext.Provider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('calendarWeek').childElementCount).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { mockHTMLElementPrototype } from 'common';
|
||||
import dayjs from 'dayjs';
|
||||
import { YearSelect } from '../components/year-select';
|
||||
import { MONTHS, DAYS_OF_WEEK } from '../constants';
|
||||
import CalendarContext from '../context';
|
||||
import type { CalendarContextProps } from '../types';
|
||||
|
||||
const yearSelectProps: CalendarContextProps = {
|
||||
selectYear: jest.fn(),
|
||||
showDate: dayjs(),
|
||||
isActiveYear: true,
|
||||
currentDate: dayjs(),
|
||||
minDate: undefined,
|
||||
maxDate: undefined,
|
||||
activeDay: {
|
||||
from: undefined,
|
||||
},
|
||||
setActiveYear: jest.fn(),
|
||||
setActiveMonth: jest.fn(),
|
||||
selectMonth: jest.fn(),
|
||||
setShowDate: jest.fn(),
|
||||
goBack: jest.fn(),
|
||||
goForward: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
handleMouseLeave: jest.fn(),
|
||||
handleDayClick: jest.fn(),
|
||||
handleDayHover: jest.fn(),
|
||||
isActiveMonth: false,
|
||||
hoverRange: {},
|
||||
months: MONTHS,
|
||||
weekDays: DAYS_OF_WEEK,
|
||||
};
|
||||
const minDate = dayjs().subtract(10, 'year');
|
||||
const maxDate = dayjs().add(10, 'year');
|
||||
const YYYY_MM_DD_FORMAT = 'YYYY-MM-DD';
|
||||
|
||||
const mockTopRect = (top: number): DOMRectReadOnly => ({
|
||||
top,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
width: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: jest.fn(),
|
||||
});
|
||||
|
||||
const renderYearCalendar = (props: CalendarContextProps) =>
|
||||
render(
|
||||
<CalendarContext.Provider value={props}>
|
||||
<YearSelect />
|
||||
</CalendarContext.Provider>
|
||||
);
|
||||
|
||||
describe('YearSelect', () => {
|
||||
beforeAll(() => mockHTMLElementPrototype());
|
||||
|
||||
it('проверяет наличие контейнера', () => {
|
||||
renderYearCalendar(yearSelectProps);
|
||||
|
||||
expect(screen.getByTestId('calendarYearSelect')).toBeDefined();
|
||||
});
|
||||
it.each([
|
||||
[true, 'visible'],
|
||||
[false, 'hidden'],
|
||||
])('при isOpen = %s visibility = %s', (isOpen, expectedVisibility) => {
|
||||
renderYearCalendar({ ...yearSelectProps, isActiveYear: isOpen });
|
||||
|
||||
expect(screen.getByTestId('calendarYearSelect')).toHaveStyle({
|
||||
visibility: expectedVisibility,
|
||||
});
|
||||
});
|
||||
it('минимальный год в выборке равен minDate.year()', () => {
|
||||
renderYearCalendar({
|
||||
...yearSelectProps,
|
||||
maxDate: maxDate.format(YYYY_MM_DD_FORMAT),
|
||||
minDate: minDate.format(YYYY_MM_DD_FORMAT),
|
||||
});
|
||||
|
||||
const years = (Array.from(screen.getByTestId('calendarYearSelect').children) as HTMLDivElement[]).find(
|
||||
el => el.dataset.empty === 'false'
|
||||
);
|
||||
|
||||
expect(years).toHaveTextContent(String(minDate.year()));
|
||||
});
|
||||
it('максимальный год в выборке равен maxDate.year()', () => {
|
||||
renderYearCalendar({
|
||||
...yearSelectProps,
|
||||
maxDate: maxDate.format(YYYY_MM_DD_FORMAT),
|
||||
minDate: minDate.format(YYYY_MM_DD_FORMAT),
|
||||
});
|
||||
|
||||
const years = (Array.from(screen.getByTestId('calendarYearSelect').children) as HTMLDivElement[]).filter(
|
||||
el => el.dataset.empty === 'false'
|
||||
);
|
||||
|
||||
expect(years[years.length - 1]).toHaveTextContent(String(maxDate.year()));
|
||||
});
|
||||
it('срабатывает onClick', () => {
|
||||
const spyFunc = jest.fn();
|
||||
|
||||
const mockReturnValue = {
|
||||
...yearSelectProps,
|
||||
maxDate: maxDate.format(YYYY_MM_DD_FORMAT),
|
||||
minDate: minDate.format(YYYY_MM_DD_FORMAT),
|
||||
selectYear: spyFunc,
|
||||
};
|
||||
|
||||
renderYearCalendar(mockReturnValue);
|
||||
|
||||
const years = screen.getByTestId('calendarYearSelect').children;
|
||||
|
||||
// Мокаем getBoundingClientRect, для активации скролла
|
||||
jest
|
||||
.spyOn(global.HTMLDivElement.prototype, 'getBoundingClientRect')
|
||||
.mockReturnValue(mockTopRect(0))
|
||||
.mockReturnValueOnce(mockTopRect(100));
|
||||
|
||||
jest.useFakeTimers();
|
||||
fireEvent.click(years[years.length - 1]);
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(spyFunc).toHaveBeenCalledTimes(1);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { CalendarState } from './types';
|
||||
import { ACTION_TYPES } from './types';
|
||||
|
||||
export const setActiveDay = (payload: CalendarState['activeDay']) => ({ type: ACTION_TYPES.SET_ACTIVE_DAY, payload });
|
||||
export const setShowDate = (payload: CalendarState['showDate']) => ({ type: ACTION_TYPES.SET_SHOW_DATE, payload });
|
||||
export const setAnimate = (payload: CalendarState['animate']) => ({ type: ACTION_TYPES.SET_ANIMATE, payload });
|
||||
export const setHoverRange = (payload: CalendarState['hoverRange']) => ({ type: ACTION_TYPES.SET_HOVER_RANGE, payload });
|
||||
export const setActiveMonth = (payload: CalendarState['isActiveMonth']) => ({ type: ACTION_TYPES.SET_ACTIVE_MONTH, payload });
|
||||
export const setActiveYear = (payload: CalendarState['isActiveYear']) => ({ type: ACTION_TYPES.SET_ACTIVE_YEAR, payload });
|
||||
export const goBack = () => ({ type: ACTION_TYPES.GO_BACK });
|
||||
export const goForward = () => ({ type: ACTION_TYPES.GO_FORWARD });
|
||||
export const selectMonth = (month: number) => ({ type: ACTION_TYPES.SELECT_MONTH, payload: month });
|
||||
export const selectYear = (year: number) => ({ type: ACTION_TYPES.SELECT_YEAR, payload: year });
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { forwardRef, useContext } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ROLE } from '@msb/fractal-ui-core';
|
||||
import { Wrapper } from '@msb/fractal-ui-styling';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import CalendarContext from '../context';
|
||||
import { useCalendarCarousel } from '../hooks';
|
||||
import { CalendarContainer, CalendarBody, DaysContainer } from '../styled';
|
||||
import Days from './days';
|
||||
import Header from './header';
|
||||
import { MonthSelect } from './month-select';
|
||||
import { VerticalCarousel } from './vertical-carousel';
|
||||
import Weeks from './weeks';
|
||||
import { YearSelect } from './year-select';
|
||||
|
||||
/**
|
||||
* Компонент календарь.
|
||||
*
|
||||
* Используется для выбора даты или диапазона дат.
|
||||
*
|
||||
* @see https://www.figma.com/file/CizcXMEqxBSENC0hXJATi3/Fractal-UI-Kit?node-id=4493%3A114610
|
||||
*/
|
||||
const CalendarDesktop = forwardRef<HTMLDivElement, { children?: ReactNode }>(({ children }, ref) => {
|
||||
const { datePickerName, activeDay, onMouseEnter, onMouseLeave } = useContext(CalendarContext);
|
||||
|
||||
const calendarCarouselProps = useCalendarCarousel((matchDate: Dayjs, isShadow: boolean, render: boolean) =>
|
||||
!isShadow && render ? <Days showDate={matchDate} /> : <DaysContainer width="calendar.daysContainerWidth" />
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
backgroundColor="bg.primary"
|
||||
borderColor="bg.secondary"
|
||||
borderRadius="calendar.S"
|
||||
borderStyle="solid"
|
||||
borderWidth="calendarContainer"
|
||||
boxShadow="calendar"
|
||||
width="fit-content"
|
||||
>
|
||||
<CalendarContainer
|
||||
ref={ref}
|
||||
data-day={activeDay.from?.date()}
|
||||
data-month={activeDay.from?.month()}
|
||||
data-name={datePickerName}
|
||||
data-role={ROLE.CALENDAR}
|
||||
data-testid="calendar"
|
||||
data-year={activeDay.from?.year()}
|
||||
role={ROLE.CALENDAR}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<Header />
|
||||
<CalendarBody>
|
||||
<Weeks />
|
||||
<VerticalCarousel {...calendarCarouselProps} />
|
||||
<MonthSelect />
|
||||
<YearSelect />
|
||||
</CalendarBody>
|
||||
</CalendarContainer>
|
||||
{children}
|
||||
</Wrapper>
|
||||
);
|
||||
});
|
||||
|
||||
CalendarDesktop.displayName = 'CalendarDesktop';
|
||||
|
||||
export default CalendarDesktop;
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useRef, useState, useCallback, useEffect, useContext } from 'react';
|
||||
import { ROLE } from '@msb/fractal-ui-core';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import CalendarContext from '../context';
|
||||
import { simpleRange } from '../helpers';
|
||||
import { CalendarContainer, CalendarBody, DaysScroller, DaysScrollerContainer } from '../styled';
|
||||
import DaysMobile from './days-mobile';
|
||||
import Header from './header';
|
||||
import { MonthSelect } from './month-select';
|
||||
import Weeks from './weeks';
|
||||
import { YearSelect } from './year-select';
|
||||
|
||||
/**
|
||||
* Календарь для мобильных устройств.
|
||||
*
|
||||
* Используется для выбора даты или диапазона дат.
|
||||
*
|
||||
* @see http://www.figma.com/file/CizcXMEqxBSENC0hXJATi3/Fractal-UI-Kit?node-id=8520%3A78847
|
||||
*/
|
||||
const CalendarMobile = React.forwardRef<HTMLDivElement, unknown>((_, ref) => {
|
||||
const lastScrollHeight = useRef<number | undefined>();
|
||||
const { datePickerName, onMouseEnter, onMouseLeave, activeDay, showDate } = useContext(CalendarContext);
|
||||
|
||||
const daysScroller = useRef<HTMLDivElement>(null);
|
||||
const [daysList, setDaysList] = useState<Dayjs[]>([showDate]);
|
||||
|
||||
useEffect(() => {
|
||||
const { from, to } = activeDay;
|
||||
|
||||
if (to) {
|
||||
setDaysList([to]);
|
||||
} else if (
|
||||
from &&
|
||||
showDate &&
|
||||
(from.date() !== showDate.date() || from.month() !== showDate.month() || from.year() !== showDate.year())
|
||||
) {
|
||||
setDaysList([from]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* При и изменении списка месяцев необходимо восстановить скролл на элемент,
|
||||
* на котором началась "подгрузка".
|
||||
*
|
||||
* На IOS все равно работает кривовато.
|
||||
*/
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (daysScroller.current && lastScrollHeight.current) {
|
||||
const scrollTop = daysScroller.current.scrollHeight - lastScrollHeight.current;
|
||||
|
||||
daysScroller.current.scrollTo({ top: scrollTop });
|
||||
lastScrollHeight.current = undefined;
|
||||
}
|
||||
});
|
||||
}, [daysList]);
|
||||
|
||||
/** При достижении краёв календаря, добавляем батч из 5 месяцев. */
|
||||
const addDate = useCallback((isFirst: boolean, isLast: boolean, target: IntersectionObserverEntry) => {
|
||||
if (isLast) {
|
||||
setDaysList(oldDays => [...oldDays, ...simpleRange(5).map(el => oldDays[oldDays.length - 1].add(el + 1, 'month'))]);
|
||||
} else if (isFirst) {
|
||||
lastScrollHeight.current =
|
||||
(daysScroller.current?.scrollHeight || 0) - target.boundingClientRect.height * (1 - target.intersectionRatio);
|
||||
|
||||
setDaysList(oldDays => [...simpleRange(5).map(el => oldDays[0].subtract(5 - el, 'month')), ...oldDays]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Обработка смены месяца. */
|
||||
const flipCalendar = useCallback(newDate => setDaysList([newDate]), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeDay.to) {
|
||||
const matchDateIndex = daysList.findIndex(d => d.isSame(activeDay.to, 'month'));
|
||||
const target = daysScroller.current?.children[matchDateIndex];
|
||||
|
||||
if (matchDateIndex !== -1 && target && daysScroller.current) {
|
||||
const onIntersect = ([{ intersectionRatio, isIntersecting }]: IntersectionObserverEntry[]) => {
|
||||
if (isIntersecting && daysScroller.current && intersectionRatio < 0.99) {
|
||||
target.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(onIntersect, {
|
||||
threshold: 0.1,
|
||||
root: daysScroller.current,
|
||||
});
|
||||
|
||||
observer.observe(target);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}
|
||||
}, [activeDay.to, daysList]);
|
||||
|
||||
const days = daysList.map((el, key) => (
|
||||
<DaysMobile
|
||||
key={el.format('YYYY-MM-DD')}
|
||||
index={key}
|
||||
intersectionTarget={daysScroller}
|
||||
monthLength={daysList.length}
|
||||
showDate={el}
|
||||
onIntersect={addDate}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<CalendarContainer
|
||||
ref={ref}
|
||||
isMobile
|
||||
data-day={activeDay.from?.date()}
|
||||
data-month={activeDay.from?.month()}
|
||||
data-name={datePickerName}
|
||||
data-role={ROLE.CALENDAR}
|
||||
data-testid="calendar"
|
||||
data-year={activeDay.from?.year()}
|
||||
role={ROLE.CALENDAR}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<Header />
|
||||
<CalendarBody width="100%">
|
||||
<Weeks />
|
||||
<DaysScrollerContainer>
|
||||
<DaysScroller ref={daysScroller} data-testid="daysScroller">
|
||||
{days}
|
||||
</DaysScroller>
|
||||
</DaysScrollerContainer>
|
||||
<MonthSelect flipCalendar={flipCalendar} />
|
||||
<YearSelect flipCalendar={flipCalendar} />
|
||||
</CalendarBody>
|
||||
</CalendarContainer>
|
||||
);
|
||||
});
|
||||
|
||||
CalendarMobile.displayName = 'CalendarMobile';
|
||||
|
||||
export default CalendarMobile;
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { Title } from '@msb/fractal-ui-styling';
|
||||
import CalendarContext from '../context';
|
||||
import { DaysMobileContainer } from '../styled';
|
||||
import type { DaysMobileProps } from '../types';
|
||||
import Days, { DummyDay } from './days';
|
||||
|
||||
/**
|
||||
* Дни в календаре.
|
||||
*/
|
||||
const DaysMobile: React.FC<DaysMobileProps> = ({ index, intersectionTarget, monthLength, onIntersect: onIntersectCallback, ...props }) => {
|
||||
const { showDate } = props;
|
||||
|
||||
const { setShowDate, months } = useContext(CalendarContext);
|
||||
const title = months[showDate.add(1, 'M').month()];
|
||||
const daysRef = useRef<HTMLDivElement>(null);
|
||||
const scrollOffset = useRef<number>(0);
|
||||
const [display, setDisplay] = useState(true);
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === monthLength - 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (intersectionTarget.current && daysRef.current) {
|
||||
const onIntersect = ([entry]: IntersectionObserverEntry[]) => {
|
||||
if (entry.isIntersecting && (isFirst || isLast)) {
|
||||
onIntersectCallback(isFirst, isLast, entry);
|
||||
}
|
||||
|
||||
const { top: containerTop } = intersectionTarget.current!.getBoundingClientRect();
|
||||
const { top: targetTop, bottom: targetBottom } = entry.boundingClientRect;
|
||||
|
||||
if (targetTop < containerTop && targetBottom > containerTop) {
|
||||
if (entry.intersectionRatio < 0.5 && entry.intersectionRatio > 0.4) {
|
||||
setShowDate(showDate);
|
||||
} else if (entry.intersectionRatio < 0.35 && (scrollOffset.current || containerTop) >= targetTop) {
|
||||
setShowDate(showDate.add(1, 'month'));
|
||||
}
|
||||
}
|
||||
|
||||
scrollOffset.current = targetTop;
|
||||
|
||||
setDisplay(entry.isIntersecting);
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(onIntersect, {
|
||||
threshold: [0.1, 0.3, 0.45],
|
||||
root: intersectionTarget.current,
|
||||
});
|
||||
|
||||
observer.observe(daysRef.current);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [intersectionTarget, isFirst, isLast, onIntersectCallback, showDate]);
|
||||
|
||||
/** Если элемент не виден (display = false), то ставим заглушку <DaysContainer />. */
|
||||
return (
|
||||
<DaysMobileContainer ref={daysRef}>
|
||||
{display ? <Days {...props} /> : <DummyDay showDate={showDate} />}
|
||||
<Title.H4 m="calendar.mobileTitleMargin">{title}</Title.H4>
|
||||
</DaysMobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
DaysMobile.displayName = 'DaysMobile';
|
||||
|
||||
export default DaysMobile;
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { ROLE } from '@msb/fractal-ui-core';
|
||||
import CalendarContext from '../context';
|
||||
import { fillMonth, getDayDataName, getDayOfWeek } from '../helpers';
|
||||
import { DaysContainer, DayWrapper, StyledDay } from '../styled';
|
||||
import type { DaysProps } from '../types';
|
||||
|
||||
/**
|
||||
* Дни в календаре.
|
||||
*/
|
||||
const Days: React.FC<Partial<Pick<DaysProps, 'showDate'>>> = ({ showDate: showDateExternal }) => {
|
||||
const {
|
||||
activeDay,
|
||||
currentDate,
|
||||
showDate: showDateInContext,
|
||||
minDate,
|
||||
maxDate,
|
||||
hoverRange,
|
||||
disabledDays,
|
||||
handleDayClick,
|
||||
handleDayHover,
|
||||
handleMouseLeave,
|
||||
isMobile,
|
||||
} = useContext(CalendarContext);
|
||||
const showDate = showDateExternal ?? showDateInContext;
|
||||
const { days, weeksCount } = fillMonth({ showDate, currentDate, disabledDays, activeDay, minDate, maxDate, range: hoverRange, isMobile });
|
||||
|
||||
return (
|
||||
<DaysContainer
|
||||
data-testid="calendarDaysContainer"
|
||||
height={isMobile && weeksCount < 6 ? 'calendar.daysContainerHeightFor5Weeks' : 'calendar.daysContainerHeight'}
|
||||
minWidth="calendar.daysContainerMinWidth"
|
||||
width={{ S: 'calendar.daysContainerWidth' }}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{days.map((day, i) => (
|
||||
<DayWrapper
|
||||
key={day.dayjsClass?.format('YYYY-MM-DD') ?? i}
|
||||
disabled={day.disabled}
|
||||
isSelectedInRange={day.isSelectedInRange}
|
||||
partOfRange={day.partOfRange}
|
||||
onClick={() => !day.disabled && handleDayClick(day)}
|
||||
onMouseOver={() => handleDayHover(day)}
|
||||
>
|
||||
<StyledDay
|
||||
data-active={day.isActive}
|
||||
data-current={day.isCurrent}
|
||||
data-date={day.dayjsClass?.format('YYYY-MM-DD')}
|
||||
data-disabled={day.disabled}
|
||||
data-name={getDayDataName({ isCurrent: day.isCurrent, partOfRange: day.partOfRange })}
|
||||
data-part-of-range={day.partOfRange}
|
||||
data-role={ROLE.ITEM}
|
||||
data-selected={day.partOfRange !== undefined}
|
||||
date={day.date}
|
||||
disabled={day.disabled}
|
||||
isActive={day.isActive}
|
||||
isCurrent={day.isCurrent}
|
||||
isSelectedInRange={day.isSelectedInRange}
|
||||
partOfRange={day.partOfRange}
|
||||
>
|
||||
{day.date || ''}
|
||||
</StyledDay>
|
||||
</DayWrapper>
|
||||
))}
|
||||
</DaysContainer>
|
||||
);
|
||||
};
|
||||
|
||||
Days.displayName = 'Days';
|
||||
|
||||
/** Список дней-заглушка. */
|
||||
export const DummyDay: React.FC<Pick<DaysProps, 'showDate'>> = ({ showDate }) => {
|
||||
const dayInMonth = showDate.daysInMonth();
|
||||
const daysBeforeMonth = getDayOfWeek(showDate.startOf('month').day());
|
||||
const daysAfterMonth = 6 - getDayOfWeek(showDate.endOf('month').day());
|
||||
const weeksCount = Math.floor((dayInMonth + daysBeforeMonth + daysAfterMonth) / 7);
|
||||
|
||||
return (
|
||||
<DaysContainer
|
||||
data-testid="calendarDummyDay"
|
||||
height={weeksCount < 6 ? 'calendar.daysContainerHeightFor5Weeks' : 'calendar.daysContainerHeight'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
DummyDay.displayName = 'DummyDay';
|
||||
|
||||
export default Days;
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { SyntheticEvent } from 'react';
|
||||
import React, { useContext, useEffect, useRef } from 'react';
|
||||
import { ROLE } from '@msb/fractal-ui-core';
|
||||
import { DownIcon, UpIcon } from '@fractal-ui/library';
|
||||
import { Wrapper } from '@msb/fractal-ui-styling';
|
||||
import CalendarContext from '../context';
|
||||
import { useMonthCarousel, useYearCarousel } from '../hooks';
|
||||
import { HeaderContainer, IconContainer, StyledHeaderButton } from '../styled';
|
||||
import { VerticalCarousel } from './vertical-carousel';
|
||||
|
||||
/** Заголовок календаря. */
|
||||
const Header: React.FC = () => {
|
||||
const { isActiveMonth, isActiveYear, isMobile, setActiveMonth, setActiveYear, goBack, goForward } = useContext(CalendarContext);
|
||||
const monthTimer = useRef<NodeJS.Timeout>();
|
||||
const yearTimer = useRef<NodeJS.Timeout>();
|
||||
|
||||
const handleMonthClick = (ev: SyntheticEvent) => {
|
||||
setActiveMonth(!isActiveMonth);
|
||||
monthTimer.current = setTimeout(() => setActiveYear(false), 100);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
const handleYearClick = (ev: SyntheticEvent) => {
|
||||
setActiveYear(!isActiveYear);
|
||||
yearTimer.current = setTimeout(() => setActiveMonth(false), 100);
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
const handleContainerClick = () => {
|
||||
setActiveYear(false);
|
||||
setActiveMonth(false);
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimeout(monthTimer.current);
|
||||
clearTimeout(yearTimer.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const monthProps = useMonthCarousel();
|
||||
const yearProps = useYearCarousel();
|
||||
|
||||
return (
|
||||
<HeaderContainer px={{ XS: 'calendar.mobileHeaderPx', S: '0' }} onClick={handleContainerClick}>
|
||||
<Wrapper display="flex" p={{ XS: 'calendar.headerPadding.XS', S: 'calendar.headerPadding.S' }}>
|
||||
<StyledHeaderButton
|
||||
data-action="monthselect"
|
||||
data-name="calendar-header-month-button"
|
||||
data-role={ROLE.BUTTON}
|
||||
data-testid="calendarHeaderMonthButton"
|
||||
fontWeight="Bold"
|
||||
isActive={isActiveMonth}
|
||||
isMobile={isMobile}
|
||||
mr="calendar.XS"
|
||||
role={ROLE.BUTTON}
|
||||
onClick={handleMonthClick}
|
||||
>
|
||||
<VerticalCarousel {...monthProps} />
|
||||
</StyledHeaderButton>
|
||||
<StyledHeaderButton
|
||||
data-action="yearselect"
|
||||
data-name="calendar-header-year-button"
|
||||
data-role={ROLE.BUTTON}
|
||||
data-testid="calendarHeaderYearButton"
|
||||
fontWeight="Bold"
|
||||
isActive={isActiveYear}
|
||||
isMobile={isMobile}
|
||||
role={ROLE.BUTTON}
|
||||
onClick={handleYearClick}
|
||||
>
|
||||
<VerticalCarousel {...yearProps} />
|
||||
</StyledHeaderButton>
|
||||
</Wrapper>
|
||||
{!isMobile && !isActiveMonth && !isActiveYear && (
|
||||
<Wrapper display="flex" p={{ XS: 'calendar.headerPadding.XS', S: 'calendar.headerPadding.S' }}>
|
||||
<IconContainer
|
||||
data-action="up"
|
||||
data-name="calendar-prev-month-chevron"
|
||||
data-testid="calendarPrevMonthChevron"
|
||||
height={32}
|
||||
role={ROLE.ICON}
|
||||
width={32}
|
||||
onClick={goBack}
|
||||
>
|
||||
<UpIcon size="S" />
|
||||
</IconContainer>
|
||||
<IconContainer
|
||||
data-action="down"
|
||||
data-name="calendar-next-month-chevron"
|
||||
data-testid="calendarNextMonthChevron"
|
||||
height={32}
|
||||
role={ROLE.ICON}
|
||||
width={32}
|
||||
onClick={goForward}
|
||||
>
|
||||
<DownIcon size="S" />
|
||||
</IconContainer>
|
||||
</Wrapper>
|
||||
)}
|
||||
</HeaderContainer>
|
||||
);
|
||||
};
|
||||
|
||||
Header.displayName = 'Header';
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { ROLE } from '@msb/fractal-ui-core';
|
||||
import { HEADER_SELECT_DELAY } from '../constants';
|
||||
import CalendarContext from '../context';
|
||||
import { usePopup } from '../hooks';
|
||||
import { PopupWindow, CalendarSelectOptionWrapper, CalendarSelectOption } from '../styled';
|
||||
import type { CalendarFlipHandler } from '../types';
|
||||
|
||||
/**
|
||||
* Компонент выбора месяца.
|
||||
*/
|
||||
export const MonthSelect: React.FC<CalendarFlipHandler> = ({ flipCalendar }) => {
|
||||
const { selectMonth, isActiveMonth: isOpen, currentDate, isMobile, showDate, months } = useContext(CalendarContext);
|
||||
|
||||
const [activeMonth, setActiveMonth] = useState<number>();
|
||||
const { isVisible, show } = usePopup({ isOpen });
|
||||
const monthTimer = useRef<NodeJS.Timeout>();
|
||||
|
||||
const handleMonthClick = (month: number) => {
|
||||
setActiveMonth(month);
|
||||
flipCalendar?.(showDate.set('month', month));
|
||||
|
||||
monthTimer.current = setTimeout(() => selectMonth(month), HEADER_SELECT_DELAY);
|
||||
};
|
||||
|
||||
useEffect(() => () => clearTimeout(monthTimer.current), []);
|
||||
|
||||
return (
|
||||
<PopupWindow data-open={isOpen} data-testid="calendarMonthSelect" isMobile={isMobile} isVisible={isVisible} show={show}>
|
||||
{months.map((month, monthNumber) => (
|
||||
<CalendarSelectOptionWrapper
|
||||
key={month}
|
||||
data-month={monthNumber + 1}
|
||||
data-role={ROLE.ITEM}
|
||||
isActive={monthNumber === activeMonth}
|
||||
role={ROLE.ITEM}
|
||||
onClick={() => handleMonthClick(monthNumber)}
|
||||
>
|
||||
<CalendarSelectOption isActive={monthNumber === activeMonth} isCurrent={monthNumber === currentDate.month()}>
|
||||
{month.slice(0, 3)}
|
||||
</CalendarSelectOption>
|
||||
</CalendarSelectOptionWrapper>
|
||||
))}
|
||||
</PopupWindow>
|
||||
);
|
||||
};
|
||||
|
||||
MonthSelect.displayName = 'MonthSelect';
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Wrapper } from '@msb/fractal-ui-styling';
|
||||
import { CarouselContainer, CarouselContent, HiddenCarouselElement } from '../styled';
|
||||
import type { VerticalCarouselProps } from '../types';
|
||||
|
||||
/**
|
||||
* Вертикальная карусель для отображения одного элемента с плавным переключением.
|
||||
*/
|
||||
export const VerticalCarousel = <T,>({
|
||||
currentState,
|
||||
animate,
|
||||
animationDuration,
|
||||
containerHeight,
|
||||
getPrevState,
|
||||
getNextState,
|
||||
renderComponent,
|
||||
compareStates,
|
||||
testID,
|
||||
}: VerticalCarouselProps<T>) => {
|
||||
const [elements, setElements] = useState<T[]>([getPrevState(currentState), currentState, getNextState(currentState)]);
|
||||
const [prevState, setPrevCurrentState] = useState<T>(currentState);
|
||||
const [instantly, setInstantly] = useState<boolean>(true);
|
||||
const [top, setTop] = useState<number>(-containerHeight);
|
||||
const shadowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
const updateState = () => {
|
||||
setInstantly(true);
|
||||
setTop(-containerHeight);
|
||||
setElements([getPrevState(currentState), currentState, getNextState(currentState)]);
|
||||
setPrevCurrentState(currentState);
|
||||
};
|
||||
|
||||
if (currentState === prevState) return;
|
||||
|
||||
if (animate) {
|
||||
setInstantly(false);
|
||||
|
||||
if (compareStates(prevState, currentState) === 1) {
|
||||
setTop(-2 * containerHeight);
|
||||
} else {
|
||||
setTop(0);
|
||||
}
|
||||
|
||||
timer = setTimeout(updateState, animationDuration);
|
||||
} else {
|
||||
updateState();
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentState, animate]);
|
||||
|
||||
return (
|
||||
<CarouselContainer height={containerHeight}>
|
||||
<CarouselContent data-testid={testID} instantly={instantly} top={top}>
|
||||
{elements.map((m, key) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Wrapper key={String(key)} data-selected={m === currentState} height={containerHeight} width={1}>
|
||||
{renderComponent(m, false, m === currentState || compareStates(m, prevState) === 0 || !instantly)}
|
||||
</Wrapper>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<HiddenCarouselElement ref={shadowRef}>{renderComponent(currentState, true, false)}</HiddenCarouselElement>
|
||||
</CarouselContainer>
|
||||
);
|
||||
};
|
||||
|
||||
VerticalCarousel.displayName = 'VerticalCarousel';
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { Text } from '@msb/fractal-ui-styling';
|
||||
import CalendarContext from '../context';
|
||||
import { WeekContainer } from '../styled';
|
||||
|
||||
/**
|
||||
* Дни недели в календаре.
|
||||
*/
|
||||
const Weeks: React.FC = () => {
|
||||
const { weekDays } = useContext(CalendarContext);
|
||||
|
||||
return (
|
||||
<WeekContainer data-testid="calendarWeek" mx={{ XS: 'calendar.mobileHeaderPx', S: '0' }} px={{ XS: 'calendar.XS', S: '0' }}>
|
||||
{weekDays.map(weekDay => (
|
||||
<Text.P3
|
||||
key={weekDay}
|
||||
alignItems="center"
|
||||
color="text.secondary"
|
||||
data-week={weekDay}
|
||||
display="flex"
|
||||
height="calendar.daySize"
|
||||
justifyContent="center"
|
||||
>
|
||||
{weekDay}
|
||||
</Text.P3>
|
||||
))}
|
||||
</WeekContainer>
|
||||
);
|
||||
};
|
||||
|
||||
Weeks.displayName = 'Weeks';
|
||||
|
||||
export default Weeks;
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { SyntheticEvent } from 'react';
|
||||
import React, { useState, useMemo, useRef, useEffect, useContext } from 'react';
|
||||
import { ROLE } from '@msb/fractal-ui-core';
|
||||
import dayjs from 'dayjs';
|
||||
import { HEADER_SELECT_DELAY } from '../constants';
|
||||
import CalendarContext from '../context';
|
||||
import { simpleRange, getAlignedMinYear } from '../helpers';
|
||||
import { usePopup } from '../hooks';
|
||||
import { PopupWindow, CalendarSelectOptionWrapper, CalendarSelectOption } from '../styled';
|
||||
import type { CalendarFlipHandler } from '../types';
|
||||
|
||||
/**
|
||||
* Выпадающий список с выбором года.
|
||||
*/
|
||||
export const YearSelect = ({ flipCalendar }: CalendarFlipHandler) => {
|
||||
const { selectYear, showDate, minDate, maxDate, isActiveYear: isOpen, currentDate, isMobile, activeDay } = useContext(CalendarContext);
|
||||
|
||||
const [activeYear, setActiveYear] = useState<number>();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const currentYearRef = useRef<HTMLDivElement>(null);
|
||||
const { isVisible, show } = usePopup({ isOpen });
|
||||
const [years, setYears] = useState<number[]>([]);
|
||||
const yearTimer = useRef<NodeJS.Timeout>();
|
||||
|
||||
const currentSelectedYear = useMemo(() => (activeDay?.from || currentDate)?.year(), [activeDay?.from, currentDate]);
|
||||
|
||||
const minDateYear = useMemo(() => (minDate ? dayjs(minDate).year() : currentSelectedYear - 24), [currentSelectedYear, minDate]);
|
||||
const maxDateYear = useMemo(() => (maxDate ? dayjs(maxDate).year() : currentSelectedYear + 24), [currentSelectedYear, maxDate]);
|
||||
|
||||
useEffect(() => {
|
||||
/** Центрирование текущего года осуществлеяется с помощью 2-х параметров: yearOffset и alignOffset. */
|
||||
let yearOffset = 0;
|
||||
let alignOffset = 0;
|
||||
|
||||
const currentYear = dayjs().year();
|
||||
|
||||
switch (currentYear - getAlignedMinYear(currentYear)) {
|
||||
case 3:
|
||||
alignOffset = -1;
|
||||
yearOffset = 2;
|
||||
break;
|
||||
case 1:
|
||||
alignOffset = 0;
|
||||
yearOffset = 1;
|
||||
break;
|
||||
default:
|
||||
alignOffset = 1;
|
||||
yearOffset = 0;
|
||||
}
|
||||
|
||||
const minYear = getAlignedMinYear(minDateYear + yearOffset) + alignOffset;
|
||||
const maxYear = getAlignedMinYear(maxDateYear + yearOffset) + alignOffset + 2;
|
||||
|
||||
const elementCount = maxYear - minYear + 1;
|
||||
|
||||
const yearsArr = simpleRange(elementCount).map(el => minYear + el);
|
||||
|
||||
setYears(yearsArr);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
const handleYearClick = (ev: SyntheticEvent<HTMLDivElement>, year: number) => {
|
||||
setActiveYear(year);
|
||||
|
||||
if (containerRef.current) {
|
||||
// Находим координаты контейнера и элемента, который мы выбрали.
|
||||
const { top: containerTop, bottom: containerBottom } = containerRef.current?.getBoundingClientRect() || {};
|
||||
const { top: elementTop, bottom: elementBottom } = ev.currentTarget.getBoundingClientRect();
|
||||
|
||||
// Если элемент виден частично, то сначала делаем элемент полностью видимым и только после этого выбираем его.
|
||||
if (containerTop && elementTop < containerTop) {
|
||||
const scrollPosition = containerRef.current?.scrollTop + (elementTop - containerTop);
|
||||
|
||||
containerRef.current.scrollTo({ top: scrollPosition, behavior: 'smooth' });
|
||||
} else if (containerBottom && elementBottom > containerBottom) {
|
||||
const scrollPosition = containerRef.current?.scrollTop + (elementBottom - containerBottom);
|
||||
|
||||
containerRef.current.scrollTo({ top: scrollPosition, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
yearTimer.current = setTimeout(() => selectYear(year), HEADER_SELECT_DELAY);
|
||||
|
||||
flipCalendar?.(showDate.set('year', year));
|
||||
};
|
||||
|
||||
useEffect(() => () => clearTimeout(yearTimer.current), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentYearRef.current || !containerRef.current || !isVisible) return;
|
||||
|
||||
containerRef.current.scrollTo({ top: currentYearRef.current.offsetTop - 110 });
|
||||
}, [currentYearRef, isVisible]);
|
||||
|
||||
return (
|
||||
<PopupWindow
|
||||
ref={containerRef}
|
||||
border="none"
|
||||
data-testid="calendarYearSelect"
|
||||
isMobile={isMobile}
|
||||
isVisible={isVisible}
|
||||
mt="calendar.S"
|
||||
show={show}
|
||||
>
|
||||
{years.map(year => {
|
||||
const isValidYear = year >= minDateYear && year <= maxDateYear;
|
||||
|
||||
return (
|
||||
<CalendarSelectOptionWrapper
|
||||
key={year}
|
||||
data-empty={!isValidYear}
|
||||
data-role={ROLE.ITEM}
|
||||
isActive={activeYear === year}
|
||||
role={ROLE.ITEM}
|
||||
onClick={(ev: SyntheticEvent<HTMLDivElement>) => isValidYear && handleYearClick(ev, year)}
|
||||
>
|
||||
{isValidYear && (
|
||||
<CalendarSelectOption
|
||||
ref={year === showDate.year() ? currentYearRef : null}
|
||||
isActive={activeYear === year}
|
||||
isCurrent={year === currentSelectedYear}
|
||||
>
|
||||
{year}
|
||||
</CalendarSelectOption>
|
||||
)}
|
||||
</CalendarSelectOptionWrapper>
|
||||
);
|
||||
})}
|
||||
</PopupWindow>
|
||||
);
|
||||
};
|
||||
|
||||
YearSelect.displayName = 'YearSelect';
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Дни недели.
|
||||
*/
|
||||
export const DAYS_OF_WEEK = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'];
|
||||
|
||||
/**
|
||||
* Месяцы.
|
||||
*/
|
||||
export const MONTHS = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
|
||||
|
||||
/** Задержка анимации. */
|
||||
export const ANIMATION_DURATION = 250;
|
||||
|
||||
/** Задержка анимации смены месяца. */
|
||||
export const CAROUSEL_ANIMATION_DURATION = 400;
|
||||
|
||||
/** Задержка анимации при наведении на день / год / месяц. */
|
||||
export const BACKGROUND_ANIMATION_DURATION = 300;
|
||||
|
||||
/** Задержка перед выбором года / месяца в заголовке. */
|
||||
export const HEADER_SELECT_DELAY = 200;
|
||||
|
||||
/** Задержка перед выбором дня. */
|
||||
export const DAY_SELECT_ANIMATION_DURATION = 150;
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import type { CalendarContextProps } from './types';
|
||||
|
||||
const CalendarContext = React.createContext<CalendarContextProps>({} as CalendarContextProps);
|
||||
|
||||
export default CalendarContext;
|
||||
@@ -0,0 +1,174 @@
|
||||
import dayjs from 'dayjs';
|
||||
import customFormat from 'dayjs/plugin/customParseFormat';
|
||||
import type { DatePickerProps } from '../date-picker/types';
|
||||
import type { Day, EmptyDayDirection, PartOfRange, FillMonthProps, RangeDate, DateValue, CalendarProps } from './types';
|
||||
|
||||
/** Для корректной отработки форматирования используем customFormat. */
|
||||
dayjs.extend(customFormat);
|
||||
|
||||
/** Функция для создания массива из n элементов. */
|
||||
export const simpleRange = (n: number): number[] => Array.from({ length: n }, (_, k) => k);
|
||||
|
||||
/** Получение дня недели. */
|
||||
export const getDayOfWeek = (date: number): number => {
|
||||
if (date === 0) return 6;
|
||||
|
||||
return date - 1;
|
||||
};
|
||||
|
||||
/** Формирование пустой ячейки дня. */
|
||||
const emptyDay = (emptyDayDirection: EmptyDayDirection): Day => ({
|
||||
isActive: false,
|
||||
isCurrent: false,
|
||||
disabled: false,
|
||||
emptyDayDirection,
|
||||
});
|
||||
|
||||
/** Единица измерения для сравнения дат. */
|
||||
const COMPARE_UNIT = 'dates';
|
||||
|
||||
/** Проверяет доступность даты. */
|
||||
export const isDisabled = ({
|
||||
dateFormat,
|
||||
disabledDays,
|
||||
maxDate,
|
||||
minDate,
|
||||
value,
|
||||
}: Pick<CalendarProps, 'disabledDays' | 'maxDate' | 'minDate'> & Pick<DatePickerProps, 'dateFormat'> & { value: dayjs.Dayjs }) =>
|
||||
!!(
|
||||
disabledDays?.some(d => dayjs(value, dateFormat).isSame(dayjs(d, dateFormat), COMPARE_UNIT)) ||
|
||||
(minDate && dayjs(value, dateFormat).isBefore(dayjs(minDate, dateFormat))) ||
|
||||
(maxDate && dayjs(value, dateFormat).isAfter(dayjs(maxDate, dateFormat)))
|
||||
);
|
||||
|
||||
/**
|
||||
* Получить список дней для календаря.
|
||||
*/
|
||||
export const fillMonth = ({
|
||||
showDate,
|
||||
currentDate,
|
||||
disabledDays,
|
||||
activeDay,
|
||||
range,
|
||||
minDate,
|
||||
maxDate,
|
||||
isMobile,
|
||||
}: FillMonthProps): { days: Day[]; weeksCount: number } => {
|
||||
const dayInMonth = showDate.daysInMonth();
|
||||
const daysBeforeMonth = getDayOfWeek(showDate.startOf('month').day());
|
||||
const daysAfterMonth = 6 - getDayOfWeek(showDate.endOf('month').day());
|
||||
|
||||
const emptyDayBeforeFunc = emptyDay.bind(null, 'previous');
|
||||
const emptyDayAfterFunc = emptyDay.bind(null, 'next');
|
||||
const weeksCount = Math.floor((dayInMonth + daysBeforeMonth + daysAfterMonth) / 7);
|
||||
const daysBefore = simpleRange(daysBeforeMonth).map(emptyDayBeforeFunc);
|
||||
let showWeekCount = daysAfterMonth;
|
||||
|
||||
if (!isMobile) {
|
||||
showWeekCount = weeksCount < 6 ? daysAfterMonth + 7 : daysAfterMonth;
|
||||
}
|
||||
|
||||
const daysAfter = simpleRange(showWeekCount).map(emptyDayAfterFunc);
|
||||
|
||||
const monthDays: Day[] = simpleRange(dayInMonth).map(index => {
|
||||
const day = showDate.set('date', index + 1);
|
||||
let partOfRange: PartOfRange | undefined;
|
||||
|
||||
if (range?.to) {
|
||||
if (day.isAfter(range.from, COMPARE_UNIT) && day.isBefore(range.to, COMPARE_UNIT)) {
|
||||
partOfRange = 'middle';
|
||||
} else if (day.isSame(range.from, COMPARE_UNIT) && day.isSame(range.to, COMPARE_UNIT)) {
|
||||
partOfRange = 'one-date-range';
|
||||
} else if (day.isSame(range.from, COMPARE_UNIT)) {
|
||||
partOfRange = 'start';
|
||||
} else if (day.isSame(range.to, COMPARE_UNIT)) {
|
||||
partOfRange = 'end';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isActive:
|
||||
/** Предпроверка на наличие activeDay.(from|to) нужно потому, что day.isSame(undefined) выдаёт true, если day = текущему дню. */
|
||||
(activeDay?.from ? day.isSame(activeDay?.from, COMPARE_UNIT) : false) ||
|
||||
(activeDay?.to ? day.isSame(activeDay?.to, COMPARE_UNIT) : false),
|
||||
isCurrent: day.isSame(currentDate, COMPARE_UNIT),
|
||||
partOfRange,
|
||||
disabled: isDisabled({ disabledDays, maxDate, minDate, value: day }),
|
||||
date: day.date(),
|
||||
dayjsClass: day,
|
||||
isSelectedInRange: partOfRange !== undefined && activeDay.from !== undefined && activeDay.to !== undefined,
|
||||
};
|
||||
});
|
||||
|
||||
/** Пометить частью выбранного диапазона пустую ячейку в календаре. */
|
||||
const fillEmptyDayPartOfRange = (day: Day): void => {
|
||||
day.partOfRange = 'middle';
|
||||
day.isSelectedInRange = activeDay.from !== undefined && activeDay.to !== undefined;
|
||||
};
|
||||
|
||||
/** Если первый/последний день является частью диапазона, то красим пустые блоки до/после. */
|
||||
if (range?.to) {
|
||||
if (monthDays[0].partOfRange === 'middle' || monthDays[0].partOfRange === 'end') {
|
||||
daysBefore.forEach(fillEmptyDayPartOfRange);
|
||||
}
|
||||
|
||||
if (monthDays[monthDays.length - 1].partOfRange === 'middle' || monthDays[monthDays.length - 1].partOfRange === 'start') {
|
||||
daysAfter.forEach(fillEmptyDayPartOfRange);
|
||||
}
|
||||
}
|
||||
|
||||
return { days: daysBefore.concat(monthDays, daysAfter), weeksCount };
|
||||
};
|
||||
|
||||
/** Инициализация изначально выбранной даты. */
|
||||
export const initializeActiveDate = (date: DateValue | undefined): RangeDate => {
|
||||
if (!date) {
|
||||
return {
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(date)) {
|
||||
const [date1, date2] = [dayjs(date[0]), date[1] ? dayjs(date[1]) : undefined];
|
||||
|
||||
if (!date2) {
|
||||
return {
|
||||
from: date1,
|
||||
to: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return date1.isBefore(date2)
|
||||
? {
|
||||
from: date1,
|
||||
to: date2,
|
||||
}
|
||||
: {
|
||||
from: date2,
|
||||
to: date1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
from: dayjs(date),
|
||||
to: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/** Получение data атрибута для дня. */
|
||||
export const getDayDataName = ({ isCurrent, partOfRange }: Pick<Day, 'isCurrent' | 'partOfRange'>): string | undefined => {
|
||||
switch (true) {
|
||||
case partOfRange === 'start':
|
||||
return 'from';
|
||||
case partOfRange === 'end':
|
||||
return 'to';
|
||||
case isCurrent:
|
||||
return 'current';
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/** Получение минимального года таким образом, чтобы всегда сохранялся порядок годов в календаре. */
|
||||
export const getAlignedMinYear = (year: number): number => Math.floor((year - 1) / 3) * 3;
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useContext, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import type { IsOpenProps } from '@msb/fractal-ui-styling';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
setActiveDay as actionSetActiveDay,
|
||||
setShowDate as actionSetShowDate,
|
||||
setHoverRange as actionSetHoverRange,
|
||||
setActiveMonth as actionSetActiveMonth,
|
||||
setActiveYear as actionSetActiveYear,
|
||||
goBack as actionGoBack,
|
||||
goForward as actionGoForward,
|
||||
selectMonth as actionSelectMonth,
|
||||
selectYear as actionSelectYear,
|
||||
} from './actions';
|
||||
import { ANIMATION_DURATION, CAROUSEL_ANIMATION_DURATION, DAYS_OF_WEEK, MONTHS } from './constants';
|
||||
import CalendarContext from './context';
|
||||
import { initializeActiveDate } from './helpers';
|
||||
import { calendarReducer } from './reducer';
|
||||
import type { PopupProps, VerticalCarouselProps, CalendarProps, Day, CalendarState, CalendarContextProps } from './types';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
/**
|
||||
* Хук для управления отображением всплывающего окна.
|
||||
*/
|
||||
export const usePopup = ({ isOpen }: IsOpenProps): PopupProps => {
|
||||
const [isVisible, setVisible] = useState(isOpen);
|
||||
const [show, setShow] = useState(isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
if (isOpen) {
|
||||
setVisible(true);
|
||||
setShow(true);
|
||||
} else {
|
||||
setShow(false);
|
||||
timer = setTimeout(() => setVisible(false), ANIMATION_DURATION);
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isOpen]);
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
show,
|
||||
};
|
||||
};
|
||||
|
||||
/** Хук для генерации параметров карусели месяца. */
|
||||
export const useMonthCarousel = (): VerticalCarouselProps<number> => {
|
||||
const { animate, showDate, months } = useContext(CalendarContext);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
currentState: showDate.month(),
|
||||
animate,
|
||||
animationDuration: CAROUSEL_ANIMATION_DURATION,
|
||||
containerHeight: 21,
|
||||
getNextState: (month: number) => showDate.set('month', month).add(1, 'month').month(),
|
||||
getPrevState: (month: number) => showDate.set('month', month).subtract(1, 'month').month(),
|
||||
renderComponent: (month: number) => months[month],
|
||||
compareStates: (prev, current) => ((prev > current && !(prev === 11 && current === 0)) || (prev === 0 && current === 11) ? -1 : 1),
|
||||
testID: 'calendarHeaderMonthLabel',
|
||||
}),
|
||||
[showDate, animate, months]
|
||||
);
|
||||
};
|
||||
|
||||
/** Хук для генерации параметров карусели года. */
|
||||
export const useYearCarousel = (): VerticalCarouselProps<number> => {
|
||||
const { animate, showDate } = useContext(CalendarContext);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
animate,
|
||||
animationDuration: CAROUSEL_ANIMATION_DURATION,
|
||||
containerHeight: 21,
|
||||
currentState: showDate.year(),
|
||||
getNextState: (year: number) => showDate.set('year', year).add(1, 'year').year(),
|
||||
getPrevState: (year: number) => showDate.set('year', year).subtract(1, 'year').year(),
|
||||
renderComponent: (year: number) => String(year),
|
||||
compareStates: (prev, current) => (prev > current ? -1 : 1),
|
||||
testID: 'calendarHeaderYearLabel',
|
||||
}),
|
||||
[showDate, animate]
|
||||
);
|
||||
};
|
||||
|
||||
/** Хук для генерации параметров карусели календаря. */
|
||||
export const useCalendarCarousel = (
|
||||
renderComponent: (date: Dayjs, isShadow: boolean, render: boolean) => JSX.Element
|
||||
): VerticalCarouselProps<Dayjs> => {
|
||||
const { animate, showDate } = useContext(CalendarContext);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
animate,
|
||||
animationDuration: CAROUSEL_ANIMATION_DURATION,
|
||||
currentState: showDate,
|
||||
containerHeight: 216,
|
||||
getNextState: (date: Dayjs) => date.add(1, 'month'),
|
||||
getPrevState: (date: Dayjs) => date.subtract(1, 'month'),
|
||||
renderComponent,
|
||||
compareStates: (prev, current) => {
|
||||
if (prev.isSame(current)) return 0;
|
||||
|
||||
return prev.isAfter(current) ? -1 : 1;
|
||||
},
|
||||
testID: 'daysCarousel',
|
||||
}),
|
||||
[showDate, animate, renderComponent]
|
||||
);
|
||||
};
|
||||
|
||||
/** Управление состоянием календаря. */
|
||||
export const useCalendar = (calendarProps: CalendarProps): CalendarContextProps => {
|
||||
const { value, isPeriod, isMobile, onChange, onHover, months = MONTHS, weekDays = DAYS_OF_WEEK } = calendarProps;
|
||||
const initializeActiveDateMemoized = useMemo(() => initializeActiveDate(value), [value]);
|
||||
const [state, dispatch] = useReducer(calendarReducer, {
|
||||
activeDay: initializeActiveDateMemoized,
|
||||
currentDate: dayjs().startOf('d'),
|
||||
showDate: dayjs().startOf('d'),
|
||||
animate: false,
|
||||
hoverRange: initializeActiveDateMemoized,
|
||||
isActiveMonth: false,
|
||||
isActiveYear: false,
|
||||
});
|
||||
const { currentDate, hoverRange, activeDay, showDate, animate, isActiveMonth, isActiveYear } = state;
|
||||
|
||||
const setActiveDay = (payload: CalendarState['activeDay']) => dispatch(actionSetActiveDay(payload));
|
||||
const setShowDate = (payload: CalendarState['showDate']) => dispatch(actionSetShowDate(payload));
|
||||
const setHoverRange = (payload: CalendarState['hoverRange']) => dispatch(actionSetHoverRange(payload));
|
||||
const setActiveMonth = (payload: CalendarState['isActiveMonth']) => dispatch(actionSetActiveMonth(payload));
|
||||
const setActiveYear = (payload: CalendarState['isActiveYear']) => dispatch(actionSetActiveYear(payload));
|
||||
const goBack = () => dispatch(actionGoBack());
|
||||
const goForward = () => dispatch(actionGoForward());
|
||||
const selectMonth = (payload: number) => dispatch(actionSelectMonth(payload));
|
||||
const selectYear = (payload: number) => dispatch(actionSelectYear(payload));
|
||||
|
||||
useEffect(() => {
|
||||
setActiveDay(initializeActiveDateMemoized);
|
||||
setHoverRange(initializeActiveDateMemoized);
|
||||
|
||||
const { from, to } = initializeActiveDateMemoized;
|
||||
|
||||
// Проверяем, что месяц или год введенного значения отличается от текущего, и отображаем в соответствии
|
||||
// с новым значением.
|
||||
// В случае с диапазоном прокручиваем до крайней даты.
|
||||
if (to && (to.month() !== showDate.month() || to.year() !== showDate.year())) {
|
||||
setShowDate(to);
|
||||
} else if (!to && from && (from.month() !== showDate.month() || from.year() !== showDate.year())) {
|
||||
setShowDate(from);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initializeActiveDateMemoized]);
|
||||
|
||||
const handleDayClick = ({ dayjsClass, emptyDayDirection }: Day) => {
|
||||
const { from, to } = activeDay;
|
||||
|
||||
if (!isMobile) {
|
||||
/** Если было нажатие на пустые дни. */
|
||||
if (emptyDayDirection === 'previous') {
|
||||
goBack();
|
||||
} else if (emptyDayDirection === 'next') {
|
||||
goForward();
|
||||
}
|
||||
}
|
||||
|
||||
if (!dayjsClass) return;
|
||||
|
||||
if (isPeriod) {
|
||||
if (from && !to) {
|
||||
let newRange = from.isBefore(dayjsClass)
|
||||
? {
|
||||
from,
|
||||
to: dayjsClass,
|
||||
}
|
||||
: {
|
||||
from: dayjsClass,
|
||||
to: from,
|
||||
};
|
||||
|
||||
if (from.isSame(dayjsClass)) {
|
||||
newRange = {
|
||||
from,
|
||||
to: from,
|
||||
};
|
||||
}
|
||||
|
||||
setActiveDay(newRange);
|
||||
onChange?.([newRange.from.toDate(), newRange.to?.toDate()]);
|
||||
} else {
|
||||
const newRange = {
|
||||
from: dayjsClass,
|
||||
to: undefined,
|
||||
};
|
||||
|
||||
setActiveDay(newRange);
|
||||
onChange?.([newRange.from.toDate(), undefined]);
|
||||
}
|
||||
} else {
|
||||
const newRange = {
|
||||
from: from?.isSame(dayjsClass, 'date') ? undefined : dayjsClass,
|
||||
to: undefined,
|
||||
};
|
||||
|
||||
setActiveDay(newRange);
|
||||
onChange?.(dayjsClass.toDate());
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!activeDay.to) {
|
||||
setHoverRange({ from: undefined, to: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDayHover = isMobile
|
||||
? noop
|
||||
: ({ dayjsClass, emptyDayDirection }: Day) => {
|
||||
const { from, to } = activeDay;
|
||||
|
||||
if (!isPeriod && dayjsClass) {
|
||||
onHover?.(dayjsClass?.toDate());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!from && dayjsClass) {
|
||||
onHover?.([dayjsClass?.toDate(), undefined]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Если было наведение по пустым ячейкам
|
||||
if (isPeriod && !dayjsClass && from && !to) {
|
||||
if (emptyDayDirection === 'previous') {
|
||||
const startOfMonth = showDate.startOf('month');
|
||||
|
||||
if (startOfMonth.isBefore(from)) {
|
||||
setHoverRange({ from: startOfMonth, to: from });
|
||||
} else {
|
||||
setHoverRange({ from, to: undefined });
|
||||
}
|
||||
} else if (emptyDayDirection === 'next') {
|
||||
const endOfMonth = showDate.endOf('month');
|
||||
|
||||
setHoverRange({ from, to: endOfMonth.isBefore(from) ? undefined : endOfMonth });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPeriod || !dayjsClass || !from || to) return;
|
||||
|
||||
if (!to && from.isSame(dayjsClass)) {
|
||||
setHoverRange({ from: undefined, to: undefined });
|
||||
onHover?.([from.toDate(), dayjsClass.toDate()]);
|
||||
} else if (from.isBefore(dayjsClass)) {
|
||||
setHoverRange({ from, to: dayjsClass });
|
||||
onHover?.([from.toDate(), dayjsClass.toDate()]);
|
||||
} else {
|
||||
setHoverRange({ from: dayjsClass, to: from });
|
||||
onHover?.([dayjsClass.toDate(), from.toDate()]);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...calendarProps,
|
||||
months,
|
||||
weekDays,
|
||||
currentDate,
|
||||
hoverRange,
|
||||
activeDay,
|
||||
showDate,
|
||||
animate,
|
||||
isActiveMonth,
|
||||
isActiveYear,
|
||||
setActiveYear,
|
||||
setActiveMonth,
|
||||
handleMouseLeave,
|
||||
handleDayClick,
|
||||
handleDayHover,
|
||||
goBack,
|
||||
goForward,
|
||||
selectYear,
|
||||
selectMonth,
|
||||
setShowDate,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { forwardRef, type PropsWithChildren } from 'react';
|
||||
import CalendarDesktop from './components/calendar-desktop';
|
||||
import CalendarMobile from './components/calendar-mobile';
|
||||
import CalendarContext from './context';
|
||||
import { useCalendar } from './hooks';
|
||||
import type { CalendarProps } from './types';
|
||||
|
||||
/**
|
||||
* Компонент календарь.
|
||||
*
|
||||
* Используется для выбора даты или диапазона дат.
|
||||
*
|
||||
* @see https://www.figma.com/file/CizcXMEqxBSENC0hXJATi3/Fractal-UI-Kit?node-id=4493%3A114610
|
||||
*/
|
||||
const Calendar = forwardRef<HTMLDivElement, PropsWithChildren<CalendarProps>>(({ children, ...props }, ref) => {
|
||||
const { isMobile } = props;
|
||||
const contextProps = useCalendar(props);
|
||||
|
||||
return (
|
||||
<CalendarContext.Provider value={contextProps}>
|
||||
{isMobile ? (
|
||||
<CalendarMobile key="mobile" ref={ref} />
|
||||
) : (
|
||||
<CalendarDesktop key="desktop" ref={ref}>
|
||||
{children}
|
||||
</CalendarDesktop>
|
||||
)}
|
||||
</CalendarContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
Calendar.displayName = 'Calendar';
|
||||
|
||||
export default Calendar;
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Action, CalendarState } from './types';
|
||||
import { ACTION_TYPES } from './types';
|
||||
|
||||
/** Редьюсер календаря. */
|
||||
export const calendarReducer = (state: CalendarState, { type, payload }: Action): CalendarState => {
|
||||
const { showDate } = state;
|
||||
|
||||
switch (type) {
|
||||
case ACTION_TYPES.SET_ACTIVE_MONTH:
|
||||
return { ...state, isActiveMonth: payload };
|
||||
case ACTION_TYPES.SET_ACTIVE_YEAR:
|
||||
return { ...state, isActiveYear: payload };
|
||||
case ACTION_TYPES.SET_ANIMATE:
|
||||
return { ...state, animate: payload };
|
||||
case ACTION_TYPES.SET_ACTIVE_DAY:
|
||||
/** Также оставляем подсветку элементов, если выбран полный диапазон. */
|
||||
return { ...state, activeDay: payload, hoverRange: payload };
|
||||
case ACTION_TYPES.SET_HOVER_RANGE:
|
||||
return { ...state, hoverRange: payload };
|
||||
case ACTION_TYPES.SET_SHOW_DATE:
|
||||
return { ...state, showDate: payload, animate: true };
|
||||
case ACTION_TYPES.GO_BACK:
|
||||
return { ...state, showDate: showDate.subtract(1, 'month'), animate: true };
|
||||
case ACTION_TYPES.GO_FORWARD:
|
||||
return { ...state, showDate: showDate.add(1, 'month'), animate: true };
|
||||
case ACTION_TYPES.SELECT_MONTH:
|
||||
return { ...state, showDate: showDate.set('month', payload), animate: false, isActiveMonth: false };
|
||||
case ACTION_TYPES.SELECT_YEAR:
|
||||
return { ...state, showDate: showDate.set('year', payload), animate: false, isActiveYear: false };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,375 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Title, Wrapper } from '@msb/fractal-ui-styling';
|
||||
import type { FractalUiTheme } from '@msb/fractal-ui-styling';
|
||||
import styledCss from '@styled-system/css';
|
||||
import type { BorderProps, LayoutProps, SpaceProps } from 'styled-system';
|
||||
import { compose, layout, space, border } from 'styled-system';
|
||||
import { ANIMATION_DURATION, CAROUSEL_ANIMATION_DURATION, BACKGROUND_ANIMATION_DURATION, DAY_SELECT_ANIMATION_DURATION } from './constants';
|
||||
import type { CalendarOption, CalendarProps, Day, HeaderButtonProps, PopupProps } from './types';
|
||||
|
||||
const bgTertiary = 'bg.tertiary';
|
||||
const bgSecondary = 'bg.secondary';
|
||||
const bgSelected = 'bg.selected';
|
||||
const daySize = 'calendar.daySize';
|
||||
const calendarM = 'calendar.M';
|
||||
const borderBox = 'border-box';
|
||||
const borderStyleSolid = 'solid';
|
||||
|
||||
/** Получения цвета текста дня. */
|
||||
const getDayColor = ({ isActive, disabled }: Pick<Day, 'disabled' | 'isActive'>): string => {
|
||||
if (disabled) return 'text.tertiary';
|
||||
else if (isActive) return 'control.inversed.typo';
|
||||
|
||||
return 'text.primary';
|
||||
};
|
||||
|
||||
/** Получение фона у обёртки дня. */
|
||||
const getDayWrapperBackground = (
|
||||
{ partOfRange, isSelectedInRange }: Pick<Day, 'isSelectedInRange' | 'partOfRange'>,
|
||||
theme: FractalUiTheme
|
||||
): string | undefined => {
|
||||
const color = isSelectedInRange ? theme.colors.bg.selected : theme.colors.bg.secondary;
|
||||
|
||||
if (partOfRange === 'end') return `linear-gradient(to left, transparent 50%, ${color} 50%)`;
|
||||
else if (partOfRange === 'start') return `linear-gradient(to left, ${color} 50%, transparent 50%)`;
|
||||
else if (partOfRange === 'middle') return color;
|
||||
};
|
||||
|
||||
/** Получение фона у обёртки дня. */
|
||||
const getDayBackgroundColour = ({
|
||||
isActive,
|
||||
partOfRange,
|
||||
isSelectedInRange,
|
||||
}: Pick<Day, 'isActive' | 'isSelectedInRange' | 'partOfRange'>): string | undefined => {
|
||||
if (isActive) return 'bg.accent';
|
||||
else if (partOfRange === 'start' || partOfRange === 'end') return isSelectedInRange ? bgSelected : bgSecondary;
|
||||
};
|
||||
|
||||
/** Стиль для центрирования элемента. */
|
||||
const centeredFlexStyle = styledCss({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
/** Стиль для текущего элемента. */
|
||||
const currentStyle = ({ isCurrent }: CalendarOption) =>
|
||||
isCurrent &&
|
||||
styledCss({
|
||||
borderColor: 'control.borderFocus',
|
||||
borderStyle: borderStyleSolid,
|
||||
borderWidth: 'calendarWeeks',
|
||||
});
|
||||
|
||||
/**
|
||||
* Контейнер для размещения календаря.
|
||||
*/
|
||||
export const CalendarContainer = styled.div<Pick<CalendarProps, 'isMobile'>>(({ isMobile }) =>
|
||||
styledCss({
|
||||
position: 'relative',
|
||||
boxSizing: borderBox,
|
||||
display: 'flex',
|
||||
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
transition: `height ${ANIMATION_DURATION / 2}ms ease-in-out`,
|
||||
|
||||
...(isMobile
|
||||
? {
|
||||
width: '100%',
|
||||
height: 'calendar.mobileContainerHeight',
|
||||
backgroundColor: 'bg.primary',
|
||||
borderColor: 'bg.secondary',
|
||||
borderStyle: borderStyleSolid,
|
||||
borderWidth: 'calendarContainer',
|
||||
borderRadius: 'calendar.S',
|
||||
}
|
||||
: {
|
||||
width: 'calendar.containerWidth',
|
||||
height: 'calendar.containerHeight',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Всплывающие окна поверх календаря.
|
||||
*/
|
||||
export const CalendarBody = styled.div<LayoutProps>(
|
||||
styledCss({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
layout
|
||||
);
|
||||
|
||||
/**
|
||||
* Контейнер для размещения дней.
|
||||
*/
|
||||
export const DaysContainer = styled.div<Pick<LayoutProps, 'height' | 'minWidth' | 'width'>>(
|
||||
({ height = 'calendar.daysContainerHeight' }) =>
|
||||
styledCss({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7,1fr)',
|
||||
alignContent: 'start',
|
||||
justifyContent: 'center',
|
||||
height,
|
||||
}),
|
||||
layout
|
||||
);
|
||||
|
||||
/**
|
||||
* Контейнер для мобильных устройств для списка дней.
|
||||
*/
|
||||
export const DaysMobileContainer = styled.div({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
/**
|
||||
* Контейнер для размещения DaysScroller на всю ширину доступного простарнства.
|
||||
*/
|
||||
export const DaysScrollerContainer = styled.div(
|
||||
styledCss({
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
mx: 'calendar.mobileDaysMx',
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Скроллер списка дней.
|
||||
*/
|
||||
export const DaysScroller = styled.div(
|
||||
styledCss({
|
||||
position: 'absolute',
|
||||
top: 'calendar.mobileScroller.top',
|
||||
right: 'calendar.mobileScroller.right',
|
||||
bottom: 'calendar.mobileScroller.bottom',
|
||||
left: 'calendar.mobileScroller.left',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'scroll',
|
||||
scrollbarWidth: 'none',
|
||||
'::-webkit-scrollbar': { display: 'none' },
|
||||
msOverflowStyle: 'none',
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Всплывающие окна поверх календаря.
|
||||
*/
|
||||
export const PopupWindow = styled.div<BorderProps & PopupProps & SpaceProps>(
|
||||
({ show, isVisible, isMobile }) =>
|
||||
styledCss({
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
|
||||
zIndex: isVisible ? 1 : 0,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignContent: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
|
||||
width: '100%',
|
||||
height: isMobile ? 'calendar.mobilePopupWindowHeight' : 'calendar.popupWindowHeight',
|
||||
overflowY: 'auto',
|
||||
visibility: isVisible ? 'visible' : 'hidden',
|
||||
backgroundColor: 'bg.primary',
|
||||
borderColor: 'control.secondary.grey.bg',
|
||||
borderStyle: borderStyleSolid,
|
||||
|
||||
borderWidth: '0',
|
||||
borderTopWidth: 'calendarWeeks',
|
||||
opacity: show ? 1 : 0,
|
||||
transition: `opacity ${ANIMATION_DURATION}ms ease-in-out`,
|
||||
}),
|
||||
compose(border, space)
|
||||
);
|
||||
|
||||
/**
|
||||
* Обёртка дня.
|
||||
*/
|
||||
export const DayWrapper = styled.div<Pick<Day, 'disabled' | 'isSelectedInRange' | 'partOfRange'>>(
|
||||
({ disabled, isSelectedInRange, partOfRange }) =>
|
||||
styledCss({
|
||||
display: 'flex',
|
||||
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: daySize,
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
background: theme => getDayWrapperBackground({ isSelectedInRange, partOfRange }, theme),
|
||||
|
||||
'hover:& > div': {
|
||||
backgroundColor: partOfRange === 'start' || partOfRange === 'end' ? bgSelected : undefined,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* День.
|
||||
*/
|
||||
export const StyledDay = styled.div<Day>(
|
||||
({ disabled, isActive, date, partOfRange, isSelectedInRange }) =>
|
||||
styledCss({
|
||||
boxSizing: borderBox,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
width: daySize,
|
||||
height: daySize,
|
||||
color: getDayColor({ disabled, isActive }),
|
||||
backgroundColor: getDayBackgroundColour({ isActive, isSelectedInRange, partOfRange }),
|
||||
borderRadius: 'round',
|
||||
transition: `background-color ease-in-out ${DAY_SELECT_ANIMATION_DURATION}ms, background ease-in-out ${DAY_SELECT_ANIMATION_DURATION}ms`,
|
||||
|
||||
'&:hover':
|
||||
isActive || !date || partOfRange || disabled
|
||||
? undefined
|
||||
: {
|
||||
backgroundColor: bgSecondary,
|
||||
},
|
||||
}),
|
||||
currentStyle
|
||||
);
|
||||
|
||||
/**
|
||||
* Контейнер для размещения дней недель.
|
||||
*/
|
||||
export const WeekContainer = styled.div<SpaceProps>(
|
||||
styledCss({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7,1fr)',
|
||||
justifyContent: 'center',
|
||||
marginBottom: calendarM,
|
||||
userSelect: 'none',
|
||||
borderColor: 'control.secondary.grey.bg',
|
||||
borderStyle: borderStyleSolid,
|
||||
borderWidth: '0',
|
||||
borderTopWidth: 'calendarWeeks',
|
||||
borderBottomWidth: 'calendarWeeks',
|
||||
}),
|
||||
space
|
||||
);
|
||||
|
||||
/**
|
||||
* Кнопки (месяца / года) в заголовке календаря.
|
||||
*/
|
||||
export const StyledHeaderButton = styled(Title.H4)<HeaderButtonProps & Pick<CalendarProps, 'isMobile'>>(({ isActive, isMobile }) =>
|
||||
styledCss({
|
||||
boxSizing: borderBox,
|
||||
height: isMobile ? 'calendar.headerButtonHeightXS' : 'calendar.headerButtonHeight',
|
||||
paddingX: calendarM,
|
||||
paddingY: 'calendar.XS',
|
||||
margin: 0,
|
||||
color: 'text.accentBrand',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: isActive ? bgSelected : 'bg.four',
|
||||
borderRadius: calendarM,
|
||||
|
||||
transition: `background-color ${BACKGROUND_ANIMATION_DURATION}ms`,
|
||||
'&:hover': {
|
||||
backgroundColor: 'bg.tertiary',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Контейнер для размещения дней.
|
||||
*/
|
||||
export const HeaderContainer = styled.div<SpaceProps>(
|
||||
styledCss({
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
}),
|
||||
space
|
||||
);
|
||||
|
||||
/**
|
||||
* Контейнер для размешения иконок.
|
||||
*/
|
||||
export const IconContainer = styled.div<LayoutProps & SpaceProps>(
|
||||
styledCss({
|
||||
color: 'control.typoPlaceholder',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
'&:hover div': {
|
||||
color: 'control.primary.bgHover',
|
||||
},
|
||||
}),
|
||||
centeredFlexStyle,
|
||||
compose(layout, space)
|
||||
);
|
||||
|
||||
/**
|
||||
* Элемент выбора года/месяца.
|
||||
*/
|
||||
export const CalendarSelectOptionWrapper = styled.div<Pick<CalendarOption, 'isActive'>>(centeredFlexStyle, ({ isActive }) =>
|
||||
styledCss({
|
||||
width: 'calendar.selectWidth',
|
||||
height: 'calendar.selectHeight',
|
||||
cursor: 'pointer',
|
||||
scrollMargin: 'calendar.scrollMargin',
|
||||
|
||||
'&:hover > div': isActive
|
||||
? undefined
|
||||
: {
|
||||
backgroundColor: bgTertiary,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Элемент выбора года/месяца.
|
||||
*/
|
||||
export const CalendarSelectOption = styled.div<CalendarOption>(
|
||||
({ isActive }) =>
|
||||
styledCss({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
width: 'calendar.optionWidth',
|
||||
height: 'calendar.optionHeight',
|
||||
padding: 'calendar.selectPadding',
|
||||
color: getDayColor({ disabled: false, isActive }),
|
||||
backgroundColor: isActive ? 'bg.accent' : undefined,
|
||||
borderRadius: calendarM,
|
||||
transition: `background-color ${BACKGROUND_ANIMATION_DURATION}ms`,
|
||||
}),
|
||||
currentStyle
|
||||
);
|
||||
|
||||
/**
|
||||
* Контейнер карусели.
|
||||
*/
|
||||
export const CarouselContainer = styled.div<LayoutProps>(
|
||||
styledCss({
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
layout
|
||||
);
|
||||
|
||||
/**
|
||||
* Контент карусели.
|
||||
*/
|
||||
export const CarouselContent = styled.div<{ top: number; instantly: boolean }>(({ top, instantly }) =>
|
||||
styledCss({
|
||||
position: 'absolute',
|
||||
top,
|
||||
transition: instantly ? undefined : `top ${CAROUSEL_ANIMATION_DURATION}ms ease-in-out`,
|
||||
})
|
||||
);
|
||||
|
||||
/** Скрытый элемент карусели, на основе которого формируется ширина контейнера. */
|
||||
export const HiddenCarouselElement = styled(Wrapper)({
|
||||
visibility: 'hidden',
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { DOMAttributes } from 'react';
|
||||
import type { IsActiveProps, IsVisibleProps, DisabledProps, AnimateProps, Route } from '@msb/fractal-ui-styling';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
/** Значение даты. */
|
||||
export type DateValue = Date | [Date, Date | undefined];
|
||||
|
||||
/**
|
||||
* Свойства календаря.
|
||||
*/
|
||||
export interface CalendarProps extends Pick<DOMAttributes<HTMLDivElement>, 'onMouseEnter' | 'onMouseLeave'> {
|
||||
/** Изначально выбранная дата. */
|
||||
value?: DateValue;
|
||||
/** Возможность выбора диапазона дат. */
|
||||
isPeriod?: boolean;
|
||||
/** Максимально возможная дата. */
|
||||
maxDate?: string;
|
||||
/** Минимально возможная дата. */
|
||||
minDate?: string;
|
||||
/** Список недоступных дат. */
|
||||
disabledDays?: string[];
|
||||
/** Имя дайтпикера для которого открыт календарь. */
|
||||
datePickerName?: string;
|
||||
/** Мобильная версия. */
|
||||
isMobile?: boolean;
|
||||
/** Названия дней недели. */
|
||||
weekDays?: string[];
|
||||
/** Названия месяцев. */
|
||||
months?: string[];
|
||||
/** Обработчик изменения выбранной даты / диапазона дат.
|
||||
*
|
||||
* @param date Выбранний день или диапазон дат.
|
||||
*/
|
||||
onChange?(date: DateValue): void;
|
||||
/** Обработчик нажатия по дню. */
|
||||
onClick?(date: Date): void;
|
||||
/** Обработчик наведения на дату. */
|
||||
onHover?(date: DateValue): void;
|
||||
}
|
||||
|
||||
/** Состояние дня, если он является частью диапазона. */
|
||||
export type PartOfRange = 'end' | 'middle' | 'one-date-range' | 'start';
|
||||
|
||||
/** Если пустая дата, то куда она ведёт при нажатии. */
|
||||
export type EmptyDayDirection = Route;
|
||||
|
||||
/** Свойства дня в календаре. */
|
||||
export interface Day extends IsActiveProps, DisabledProps {
|
||||
/** Состояние текущего дня.
|
||||
*
|
||||
* Если true, то рамка будет цвета 'control.borderFocus'.
|
||||
*/
|
||||
isCurrent?: boolean;
|
||||
/** День. */
|
||||
date?: number;
|
||||
/** Обёртка dayjs для работы с днём. */
|
||||
dayjsClass?: Dayjs;
|
||||
/** Состояние дня, если он является частью диапазона. */
|
||||
partOfRange?: PartOfRange;
|
||||
/** Если участвует в выбранном диапазоне. */
|
||||
isSelectedInRange?: boolean;
|
||||
/** Если пустая дата, то куда она ведёт при нажатии. */
|
||||
emptyDayDirection?: EmptyDayDirection;
|
||||
}
|
||||
|
||||
/** Состояние выбора диапазона дат. */
|
||||
export interface RangeDate {
|
||||
/** Начальный день. */
|
||||
from?: Dayjs;
|
||||
/** Конечный день. */
|
||||
to?: Dayjs;
|
||||
}
|
||||
|
||||
/** Свойства кнопки (месяца / года) в заголовке календаря. */
|
||||
export type HeaderButtonProps = IsActiveProps;
|
||||
|
||||
/** Свойства комопнента дней календаря. */
|
||||
export interface DaysProps extends Pick<CalendarProps, 'disabledDays' | 'isMobile' | 'maxDate' | 'minDate'> {
|
||||
/** Выбранные дни. */
|
||||
activeDay: RangeDate;
|
||||
/** Текущий день. */
|
||||
currentDate: Dayjs;
|
||||
/** Месяц на основе которого формируется календарь. */
|
||||
showDate: Dayjs;
|
||||
/** Выбранный диапазон при наведении. */
|
||||
hoverRange: RangeDate;
|
||||
/** Обработчик нажатия. */
|
||||
onClick(day: Day): void;
|
||||
/** Обработчик наведения на дня. */
|
||||
onHoverDay(day: Day): void;
|
||||
/** Обработчик выхода курсора из элемета. */
|
||||
onMouseLeave(): void;
|
||||
}
|
||||
|
||||
/** Пропсы обёртки дня для мобильных устройств. */
|
||||
export interface DaysMobileProps {
|
||||
/** Элемент, на основе которого будет вычисляться видимость блока дней. */
|
||||
intersectionTarget: React.RefObject<HTMLDivElement>;
|
||||
/** Порядковый номер в списке отображаемых месяцев. */
|
||||
index: number;
|
||||
/** Кол-во месяцев. */
|
||||
monthLength: number;
|
||||
/** Обработчик выхода и входа элемента. */
|
||||
onIntersect(isFirst: boolean, isLast: boolean, el: IntersectionObserverEntry): void;
|
||||
/** Месяц, на основе которого формируется календарь. */
|
||||
showDate: Dayjs;
|
||||
}
|
||||
|
||||
/** Свойства, принимаемые функцией fillMonth. */
|
||||
export interface FillMonthProps extends Omit<DaysProps, 'hoverRange' | 'onClick' | 'onHoverDay' | 'onMouseLeave'> {
|
||||
/** Выбранной диапазон дат. */
|
||||
range?: RangeDate;
|
||||
}
|
||||
|
||||
/** Свойства дня в календаре. */
|
||||
export type CalendarOption = Pick<Day, 'isActive' | 'isCurrent'>;
|
||||
|
||||
/** Свойства всплывающего окна. */
|
||||
export interface PopupProps extends IsVisibleProps, Pick<CalendarProps, 'isMobile'> {
|
||||
/** Признак видимости элемента. */
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
/** Свойства компонента "заголовок месяца". */
|
||||
export interface VerticalCarouselProps<T> extends AnimateProps {
|
||||
/** Текущее состояние, которое нужно отобразить. */
|
||||
currentState: T;
|
||||
/** Скорость переключения элемента. */
|
||||
animationDuration: number;
|
||||
/** Высота контейнера. */
|
||||
containerHeight: number;
|
||||
/** Функция для получения предыдущего состояния. */
|
||||
getPrevState(state: T): T;
|
||||
/** Функция для получения следующего состояния. */
|
||||
getNextState(state: T): T;
|
||||
/** Функция для рендера компонента. */
|
||||
renderComponent(state: T, isShadow: boolean, render: boolean): JSX.Element | string;
|
||||
/** Функция для сравнения двух состояний, для определения направления переключения. */
|
||||
compareStates(prev: T, current: T): -1 | 0 | 1;
|
||||
/** ID для тестов. */
|
||||
testID: string;
|
||||
}
|
||||
|
||||
/** Интерфейс описывающий свойства экшенов. */
|
||||
export interface Action {
|
||||
type: ACTION_TYPES;
|
||||
payload?: any;
|
||||
}
|
||||
|
||||
/** Состояние календаря. */
|
||||
export interface CalendarState extends Pick<DaysProps, 'activeDay' | 'currentDate' | 'hoverRange' | 'showDate'>, AnimateProps {
|
||||
/** Показывать вкладку выбора месяца. */
|
||||
isActiveMonth: boolean;
|
||||
/** Показывать вкладку выбора года. */
|
||||
isActiveYear: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarContextProps
|
||||
extends Omit<CalendarProps, 'months' | 'weekDays'>,
|
||||
Required<Pick<CalendarProps, 'months' | 'weekDays'>>,
|
||||
CalendarState {
|
||||
/** Управление состоянием isActiveYear. */
|
||||
setActiveYear(year: boolean): void;
|
||||
/** Управление состоянием isActiveMonth. */
|
||||
setActiveMonth(month: boolean): void;
|
||||
/** Обработчик события mouseLeave. */
|
||||
handleMouseLeave(): void;
|
||||
/** Обработчик нажатия на день. */
|
||||
handleDayClick(day: Day): void;
|
||||
/** Обработчик наведения на день. */
|
||||
handleDayHover(day: Day): void;
|
||||
/** Переместиться на месяц назад. */
|
||||
goBack(): void;
|
||||
/** Переместиться на месяц вперёд. */
|
||||
goForward(): void;
|
||||
/** Выбрать, отображаемый в календаре, год. */
|
||||
selectYear(year: number): void;
|
||||
/** Выбрать, отображаемый в календаре, месяц. */
|
||||
selectMonth(month: number): void;
|
||||
/** Манипуляция состоянием showDate. */
|
||||
setShowDate(date: Dayjs): void;
|
||||
}
|
||||
|
||||
/** Обработчик смены месяца/года. */
|
||||
export interface CalendarFlipHandler {
|
||||
/** Обработчик перелистывания календаря. */
|
||||
flipCalendar?(date: Dayjs): void;
|
||||
}
|
||||
|
||||
/** Типы экшенов для редьюсера. */
|
||||
export enum ACTION_TYPES {
|
||||
SET_ACTIVE_DAY = 'SET_ACTIVE_DAY',
|
||||
SET_SHOW_DATE = 'SET_SHOW_DATE',
|
||||
SET_ANIMATE = 'SET_ANIMATE',
|
||||
SET_HOVER_RANGE = 'SET_HOVER_RANGE',
|
||||
SET_ACTIVE_MONTH = 'SET_ACTIVE_MONTH',
|
||||
SET_ACTIVE_YEAR = 'SET_ACTIVE_YEAR',
|
||||
GO_BACK = 'GO_BACK',
|
||||
GO_FORWARD = 'GO_FORWARD',
|
||||
SELECT_MONTH = 'SELECT_MONTH',
|
||||
SELECT_YEAR = 'SELECT_YEAR',
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { Canvas, Description, Source, Heading, Title, Controls } from '@storybook/blocks';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import CaptchaInput from '..';
|
||||
import { CaptchaInputStory, StatesStory } from './examples';
|
||||
|
||||
export { CaptchaInputStory, StatesStory };
|
||||
CaptchaInputStory.storyName = 'Песочница';
|
||||
CaptchaInputStory.args = {
|
||||
name: 'captchaInput',
|
||||
label: 'Label',
|
||||
width: '350px',
|
||||
maxWidth: '',
|
||||
tooltip: 'Tooltip',
|
||||
value: '',
|
||||
url: '/images/captcha-1.png',
|
||||
};
|
||||
CaptchaInputStory.argTypes = {
|
||||
value: {
|
||||
control: 'text',
|
||||
},
|
||||
};
|
||||
|
||||
StatesStory.storyName = 'Состояния';
|
||||
|
||||
const Docs: React.VFC = () => (
|
||||
<>
|
||||
<Title>CaptchaInput</Title>
|
||||
<Description of={CaptchaInput} />
|
||||
<h4>Использование компонента:</h4>
|
||||
<ul>
|
||||
<li>Компонент используется в связке CaptchaData API.</li>
|
||||
<li>При помощи метода captchaService.generate() получаем id капчи.</li>
|
||||
<li>Проверка происходит с помощью метода validate CaptchaData API.</li>
|
||||
</ul>
|
||||
<h4>Обязательные пропсы компонента:</h4>
|
||||
<ol>
|
||||
<li>name - имя инпута, необходимо для использования в форме.</li>
|
||||
<li>label - Текст над полем ввода инпута.</li>
|
||||
<li>url - url картинки, получаемый из метода captchaService.getCaptchaUrl, в который передается id.</li>
|
||||
<li>onRefreshCaptchaClick - метод для обновления картинки капчи.</li>
|
||||
</ol>
|
||||
<Source
|
||||
code={`
|
||||
// импорт компонента CaptchaInput
|
||||
import { CaptchaInput } from '@msb/fractal-ui-composites';
|
||||
|
||||
// Компонент для ввода капчи.
|
||||
<CaptchaInput name='captcha' onRefreshCaptchaClick={() => {}} label={'Код с картинки'} url={'url'} />
|
||||
`}
|
||||
language="tsx"
|
||||
/>
|
||||
|
||||
<Heading>Состояния</Heading>
|
||||
<Canvas of={StatesStory} />
|
||||
|
||||
<Heading>Песочница</Heading>
|
||||
<Canvas of={CaptchaInputStory} />
|
||||
|
||||
<Heading>API</Heading>
|
||||
<Controls of={CaptchaInputStory} />
|
||||
</>
|
||||
);
|
||||
|
||||
const StoryMeta: Meta = {
|
||||
title: 'Компоненты формы/CaptchaInput',
|
||||
component: CaptchaInput,
|
||||
argTypes: {
|
||||
onChange: {
|
||||
table: { control: false },
|
||||
},
|
||||
onRefreshCaptchaClick: {
|
||||
table: { control: false },
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
page: Docs,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default StoryMeta;
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import type { FieldComponents } from '@msb/fractal-ui-form';
|
||||
import { Fields } from '@msb/fractal-ui-form';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { useArgs } from '@storybook/preview-api';
|
||||
import type { StoryFn } from '@storybook/react';
|
||||
import { SandboxField, SandboxStoryForm } from 'common';
|
||||
import CaptchaInput from '..';
|
||||
import type { CaptchaInputPropsType } from '../types';
|
||||
|
||||
const captchaUrlList = ['/images/captcha-1.png', '/images/captcha-2.png'];
|
||||
|
||||
export const CaptchaInputStory: StoryFn<CaptchaInputPropsType> = args => {
|
||||
const [values, updateArgs] = useArgs();
|
||||
const refreshTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const onRefreshCaptchaClick = () => {
|
||||
action('onRefreshCaptchaClick');
|
||||
updateArgs({ ...args, url: '' });
|
||||
refreshTimer.current = setTimeout(() => {
|
||||
const newUrl = values.url === captchaUrlList[0] ? captchaUrlList[1] : captchaUrlList[0];
|
||||
|
||||
updateArgs({ ...args, url: newUrl });
|
||||
clearTimeout(refreshTimer.current);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const onChange: CaptchaInputPropsType['onChange'] = value => {
|
||||
action('onChange')(value);
|
||||
updateArgs({ ...args, value });
|
||||
};
|
||||
|
||||
const onChangeField: ComponentProps<FieldComponents['CaptchaInput']>['onChange'] = ({ value }) => onChange(value);
|
||||
|
||||
return (
|
||||
<SandboxStoryForm
|
||||
fieldComponent={
|
||||
<SandboxField<'CaptchaInput'>
|
||||
Field={Fields.CaptchaInput}
|
||||
{...(args as ComponentProps<FieldComponents['CaptchaInput']>)}
|
||||
onChange={onChangeField}
|
||||
onRefreshCaptchaClick={onRefreshCaptchaClick}
|
||||
/>
|
||||
}
|
||||
fieldType="CaptchaInput"
|
||||
mainComponent={<CaptchaInput {...args} onChange={onChange} onRefreshCaptchaClick={onRefreshCaptchaClick} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatesStory: StoryFn<CaptchaInputPropsType> = () => (
|
||||
<>
|
||||
<h4>Загрузка капчи</h4>
|
||||
<CaptchaInput label="label" name="captcha" url="" width="350px" onRefreshCaptchaClick={action('onRefreshCaptchaClick')} />
|
||||
<h4>Ошибка инпута</h4>
|
||||
<CaptchaInput hasError label="label" name="captcha" url="" width="350px" onRefreshCaptchaClick={action('onRefreshCaptchaClick')} />
|
||||
<h4>Ворнинг инпута</h4>
|
||||
<CaptchaInput hasWarning label="label" name="captcha" url="" width="350px" onRefreshCaptchaClick={action('onRefreshCaptchaClick')} />
|
||||
<h4>С загруженной картинкой</h4>
|
||||
<CaptchaInput
|
||||
label="Label"
|
||||
name="captcha"
|
||||
tooltip="Tooltip"
|
||||
url="/images/captcha-1.png"
|
||||
width="350px"
|
||||
onRefreshCaptchaClick={action('onRefreshCaptchaClick')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { ROLE } from '@msb/fractal-ui-core';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import CaptchaInput from '..';
|
||||
|
||||
const defaultProps = {
|
||||
url: '',
|
||||
name: 'test-input',
|
||||
label: 'test-label',
|
||||
renewCaptcha: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('../../../../styling/src/hooks/use-breakpoints');
|
||||
|
||||
describe('CaptchaInput', () => {
|
||||
test('при клике на иконку перезагрузки капчи вызывается метод onRenewCaptcha', async () => {
|
||||
const renewCaptcha = jest.fn();
|
||||
|
||||
render(<CaptchaInput {...defaultProps} onRefreshCaptchaClick={renewCaptcha} />);
|
||||
fireEvent.click(screen.getByTestId('renew captcha'));
|
||||
|
||||
await waitFor(() => expect(renewCaptcha).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
test('если не передан value, то значение инпута будет пустая строка', async () => {
|
||||
render(<CaptchaInput {...defaultProps} onRefreshCaptchaClick={jest.fn()} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId(ROLE.INPUT)).toHaveValue(''));
|
||||
});
|
||||
test('после обновления капчи происходит фокус на поле ввода инпута', async () => {
|
||||
render(<CaptchaInput {...defaultProps} onRefreshCaptchaClick={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('renew captcha'));
|
||||
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByTestId(ROLE.INPUT)));
|
||||
});
|
||||
test('если url пустая строка, то на месте картинки отражается спиннер загрузки', async () => {
|
||||
render(<CaptchaInput {...defaultProps} onRefreshCaptchaClick={jest.fn()} />);
|
||||
await waitFor(() => expect(screen.getByTestId('spinner')).toBeInTheDocument());
|
||||
});
|
||||
test('если url передан, то спиннер загрузки не отображается', async () => {
|
||||
render(<CaptchaInput {...defaultProps} url={'test-url'} onRefreshCaptchaClick={jest.fn()} />);
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('spinner')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('отображается label', async () => {
|
||||
const { getByText } = render(<CaptchaInput {...defaultProps} onRefreshCaptchaClick={jest.fn()} />);
|
||||
|
||||
await waitFor(() => expect(getByText(defaultProps.label)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('отображается tooltip', async () => {
|
||||
const { queryByTestId } = render(<CaptchaInput {...defaultProps} tooltip="tooltip" onRefreshCaptchaClick={jest.fn()} />);
|
||||
|
||||
await waitFor(() => expect(queryByTestId('tooltip')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { ROLE, Spinner } from '@msb/fractal-ui-core';
|
||||
import { LoopArrowIcon } from '@fractal-ui/library';
|
||||
import { Wrapper } from '@msb/fractal-ui-styling';
|
||||
import { DEFAULT_SIZE } from '../constants';
|
||||
import Input from '../input';
|
||||
import { TooltipIcon } from '../inputs-label/tooltip-icon';
|
||||
import { mergeRefs } from '../utils';
|
||||
import { StyledImage, StyledLabel } from './styled';
|
||||
import type { CaptchaInputPropsType } from './types';
|
||||
|
||||
const topRowHeightMap = {
|
||||
XS: 5,
|
||||
S: 6,
|
||||
M: 6,
|
||||
L: 7,
|
||||
};
|
||||
|
||||
/**
|
||||
* Компонент для ввода символов с картинки (капча).
|
||||
*/
|
||||
const CaptchaInput = React.forwardRef<HTMLInputElement, CaptchaInputPropsType>(
|
||||
(
|
||||
{
|
||||
onRefreshCaptchaClick,
|
||||
url,
|
||||
maxWidth,
|
||||
width,
|
||||
size = DEFAULT_SIZE,
|
||||
tooltip,
|
||||
label,
|
||||
errorText,
|
||||
warningText,
|
||||
name,
|
||||
tooltipPosition = 'bottom center',
|
||||
disabled,
|
||||
color = 'control.typoPlaceholder',
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const inputSize = `input.${size}`;
|
||||
const inputRef = useRef<HTMLInputElement | undefined>();
|
||||
|
||||
const refreshCaptchaClickHandler = () => {
|
||||
onRefreshCaptchaClick();
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const labelColor = disabled ? 'control.disabled.typo' : color;
|
||||
|
||||
return (
|
||||
<Wrapper maxWidth={maxWidth} width={width}>
|
||||
<Wrapper display="flex">
|
||||
<StyledLabel color={labelColor} size={size}>
|
||||
{label}
|
||||
{tooltip && (
|
||||
<TooltipIcon color={labelColor} disabled={disabled} name={name} tooltip={tooltip} tooltipPosition={tooltipPosition} />
|
||||
)}
|
||||
<Wrapper
|
||||
alignItems="center"
|
||||
data-role={ROLE.BUTTON}
|
||||
data-testid={'renew captcha'}
|
||||
display="flex"
|
||||
height={topRowHeightMap[size]}
|
||||
justifyContent="center"
|
||||
ml="auto"
|
||||
width="20px"
|
||||
onClick={refreshCaptchaClickHandler}
|
||||
>
|
||||
<LoopArrowIcon color="control.typoPlaceholder" cursor="pointer" height="14px" size="S" width="14px" />
|
||||
</Wrapper>
|
||||
</StyledLabel>
|
||||
</Wrapper>
|
||||
<Wrapper display="grid" gridGap={3} gridTemplateColumns="1fr 1fr">
|
||||
<Input
|
||||
{...rest}
|
||||
ref={mergeRefs((instance: HTMLInputElement) => (inputRef.current = instance), ref)}
|
||||
disabled={disabled}
|
||||
errorText={errorText}
|
||||
name={name}
|
||||
size={size}
|
||||
warningText={warningText}
|
||||
/>
|
||||
<Wrapper
|
||||
alignItems="center"
|
||||
backgroundColor="bg.primary"
|
||||
borderColor="control.border"
|
||||
borderRadius={inputSize}
|
||||
borderStyle="solid"
|
||||
borderWidth="input"
|
||||
boxSizing="border-box"
|
||||
display="flex"
|
||||
height={inputSize}
|
||||
justifyContent="center"
|
||||
width="100%"
|
||||
>
|
||||
{url?.length > 0 ? <StyledImage alt={'captcha image'} src={url} /> : <Spinner dataName="spinner" size="L" />}
|
||||
</Wrapper>
|
||||
</Wrapper>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CaptchaInput.displayName = 'CaptchaInput';
|
||||
export default CaptchaInput;
|
||||
@@ -0,0 +1,21 @@
|
||||
import styled from '@emotion/styled';
|
||||
import styledCss from '@styled-system/css';
|
||||
import type { LayoutProps } from 'styled-system';
|
||||
import { StyledTypography } from '../inputs-label/styled';
|
||||
|
||||
export const StyledImage = styled.img<LayoutProps>(
|
||||
styledCss({
|
||||
height: '100%',
|
||||
backgroundColor: 'bg.primary',
|
||||
})
|
||||
);
|
||||
|
||||
/** Обёртка для лейбла компонента. */
|
||||
export const StyledLabel = styled(StyledTypography)(
|
||||
styledCss({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
mb: 3,
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { BaseInputProps } from '../input';
|
||||
|
||||
/**
|
||||
* Тип, описывающие свойства компонента CaptchaInput.
|
||||
*/
|
||||
export type CaptchaInputPropsType = Omit<BaseInputProps, 'label'> & {
|
||||
/** Колбэк, обновляющий картнку капчи. Вызывается при клике на LoopArrowIcon. */
|
||||
onRefreshCaptchaClick(): void;
|
||||
/** Url картинки капчи. */
|
||||
url: string;
|
||||
/** Лейбл поля ввода капчи. */
|
||||
label: string;
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { CardSelectItemProps } from '@msb/fractal-ui-composites';
|
||||
import { Button } from '@msb/fractal-ui-core';
|
||||
import { Wrapper } from '@msb/fractal-ui-styling';
|
||||
import type { StoryFn } from '@storybook/react';
|
||||
import CardSelectItem from '../card-select-item';
|
||||
|
||||
const CardSelectItemTemplate = ({ ...props }: CardSelectItemProps) => {
|
||||
const [checked, setChecked] = useState(props.checked);
|
||||
|
||||
return (
|
||||
<Wrapper alignItems="center" backgroundColor="bg.secondary" display="flex" flexWrap="wrap" justifyContent="space-between" p="20px">
|
||||
<CardSelectItem {...props} checked={checked} onChange={() => setChecked(true)} />
|
||||
<Button dataAction="check" onClick={() => setChecked(false)}>
|
||||
Сбросить выбор
|
||||
</Button>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
CardSelectItemTemplate.displayName = 'CardSelectItemTemplate';
|
||||
|
||||
export const SandboxStory: StoryFn<CardSelectItemProps> = args => <CardSelectItemTemplate {...args} />;
|
||||
|
||||
export const StructureStory: StoryFn<CardSelectItemProps> = args => <CardSelectItemTemplate {...args} />;
|
||||
|
||||
export const LabelOnlyStory: StoryFn<CardSelectItemProps> = args => <CardSelectItemTemplate {...args} />;
|
||||
|
||||
export const ContentOnlyStory: StoryFn<CardSelectItemProps> = args => <CardSelectItemTemplate {...args} />;
|
||||
|
||||
export const SizesStory: StoryFn<CardSelectItemProps> = args => (
|
||||
<>
|
||||
<Wrapper backgroundColor="bg.secondary" fontWeight="700" padding="20px 20px 0">
|
||||
Размер L
|
||||
</Wrapper>
|
||||
<CardSelectItemTemplate {...args} size="L" />
|
||||
<br />
|
||||
<Wrapper backgroundColor="bg.secondary" fontWeight="700" padding="20px 20px 0">
|
||||
Размер M
|
||||
</Wrapper>
|
||||
<CardSelectItemTemplate {...args} size="M" />
|
||||
<br />
|
||||
<Wrapper backgroundColor="bg.secondary" fontWeight="700" padding="20px 20px 0">
|
||||
Размер S
|
||||
</Wrapper>
|
||||
<CardSelectItemTemplate {...args} size="S" />
|
||||
</>
|
||||
);
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { Title, Controls, Description, Source, Heading, Canvas } from '@storybook/blocks';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { StoryCode } from 'common';
|
||||
import CardSelectItem from '../card-select-item';
|
||||
|
||||
import { SandboxStory, StructureStory, LabelOnlyStory, ContentOnlyStory, SizesStory } from './card-select-item.examples';
|
||||
import { cardSelectItemDefaultArgs } from './constants';
|
||||
|
||||
export { SandboxStory, StructureStory, LabelOnlyStory, ContentOnlyStory, SizesStory };
|
||||
SandboxStory.storyName = 'Песочница';
|
||||
StructureStory.storyName = 'Структура';
|
||||
LabelOnlyStory.storyName = 'Только с лейблом';
|
||||
LabelOnlyStory.args = { ...cardSelectItemDefaultArgs, content: null };
|
||||
ContentOnlyStory.storyName = 'Только с контентом';
|
||||
ContentOnlyStory.args = { ...cardSelectItemDefaultArgs, hideLabel: true };
|
||||
SizesStory.storyName = 'Размеры';
|
||||
|
||||
const Docs: React.FC = () => (
|
||||
<>
|
||||
<Title>CardSelectItem</Title>
|
||||
<Description of={CardSelectItem} />
|
||||
<Source
|
||||
code={`
|
||||
// импорт компонента CardSelectItem
|
||||
import { CardSelectItem } from '@msb/fractal-ui-composites';
|
||||
import { DocTickIcon } from '@fractal-ui/library';
|
||||
|
||||
<CardSelectItem
|
||||
name="card-select"
|
||||
icon={DocTickIcon}
|
||||
label={'Расчётный в рублях'}
|
||||
content={<Badge type="success">Расчётный в рублях</Badge>}
|
||||
value={'0'}
|
||||
/>`}
|
||||
language="tsx"
|
||||
/>
|
||||
|
||||
<Heading>Структура</Heading>
|
||||
<p>
|
||||
Секция состоит из компонента Pictogram, лейбла и свободного контента. Текст лейбла обязательно должен быть задан (в CardSelect на XS
|
||||
используется для выбранного значения), контент является не обязателеным. В состоянии Hover добавляется фон и меняется цвет лейбла; в
|
||||
состоянии Active фон, цвет лейбла и иконка галочки, а также меняется расцветка пиктограммы.
|
||||
</p>
|
||||
<Canvas of={StructureStory} />
|
||||
|
||||
<Heading>Только с лейблами </Heading>
|
||||
<Canvas of={LabelOnlyStory} />
|
||||
|
||||
<Heading>Только с контентом </Heading>
|
||||
<Canvas of={ContentOnlyStory} />
|
||||
|
||||
<Heading>Размеры</Heading>
|
||||
<p>Компонент имеет 3 размера. По умолчанию используется L.</p>
|
||||
<Canvas of={SizesStory} />
|
||||
|
||||
<Heading>Основные состояния</Heading>
|
||||
<p>Default, Hover, Active/Checked, Disabled.</p>
|
||||
|
||||
<Heading>Взаимодействие</Heading>
|
||||
<p>Область ховера/нажатия равна секции.</p>
|
||||
|
||||
<Heading>Адаптивность</Heading>
|
||||
<p>
|
||||
Брейкпоинт XS - на смартфоне лейбл располагается справа от компонента Pictogram. Иконка галочки отсутствует.
|
||||
<br />
|
||||
<br />
|
||||
Брейкпоинт XL - лейбл располагается справа от компонента Pictogram.
|
||||
<br />
|
||||
<br />
|
||||
Меняйте размер канвы, чтобы увидеть изменения.
|
||||
</p>
|
||||
|
||||
<Heading>Анимация</Heading>
|
||||
<p>Секция меняет свои состояния фона, лейбла, иконки с фейдом 200 мс.</p>
|
||||
|
||||
<Heading>Доступность с клавиатуры</Heading>
|
||||
<p>Используется стандартный фокус браузера.</p>
|
||||
|
||||
<Heading>Автотесты</Heading>
|
||||
<p>Компонент CardSelectItem имеет следующие атрибуты, описанные ниже.</p>
|
||||
<h2>
|
||||
<code>[data-role]</code>
|
||||
</h2>
|
||||
<p>
|
||||
Секция имеет значение <StoryCode>data-role=radiogroup</StoryCode>.
|
||||
</p>
|
||||
|
||||
<Heading>Песочница</Heading>
|
||||
<Canvas of={SandboxStory} />
|
||||
|
||||
<Heading>API</Heading>
|
||||
<Controls of={SandboxStory} />
|
||||
</>
|
||||
);
|
||||
|
||||
const StoryMeta: Meta = {
|
||||
title: 'Компоненты формы/CardSelectItem',
|
||||
component: CardSelectItem,
|
||||
argTypes: {
|
||||
flexBasis: { type: 'string' },
|
||||
icon: { control: false },
|
||||
content: { control: false },
|
||||
value: { type: 'string' },
|
||||
checked: { control: { type: 'boolean' }, defaultValue: false },
|
||||
hideLabel: { control: { type: 'boolean' }, defaultValue: false },
|
||||
disabled: { control: { type: 'boolean' }, defaultValue: false },
|
||||
size: { defaultValue: 'L' },
|
||||
},
|
||||
args: cardSelectItemDefaultArgs,
|
||||
parameters: {
|
||||
docs: {
|
||||
page: Docs,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default StoryMeta;
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import React from 'react';
|
||||
import type { CardSelectProps } from '@msb/fractal-ui-composites';
|
||||
import type { FieldComponents } from '@msb/fractal-ui-form';
|
||||
import { Fields } from '@msb/fractal-ui-form';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { useArgs } from '@storybook/preview-api';
|
||||
import type { StoryFn } from '@storybook/react';
|
||||
import { SandboxField, SandboxStoryForm } from 'common';
|
||||
import CardSelect from '..';
|
||||
|
||||
const CardSelectTemplateStory: StoryFn<CardSelectProps> = props => {
|
||||
const [_, updateArgs] = useArgs();
|
||||
|
||||
const onChange: CardSelectProps['onChange'] = value => {
|
||||
action('onChange')(value);
|
||||
updateArgs({ ...props, value });
|
||||
};
|
||||
|
||||
return <CardSelect {...props} onChange={onChange} />;
|
||||
};
|
||||
|
||||
export const SandboxStory: StoryFn<CardSelectProps> = args => {
|
||||
const [_, updateArgs] = useArgs();
|
||||
|
||||
const onChange: CardSelectProps['onChange'] = value => {
|
||||
action('onChange')(value);
|
||||
updateArgs({ ...args, value });
|
||||
};
|
||||
|
||||
const onChangeField: ComponentProps<FieldComponents['CardSelect']>['onChange'] = ({ value }) => value && onChange(value);
|
||||
|
||||
return (
|
||||
<SandboxStoryForm
|
||||
fieldComponent={
|
||||
<SandboxField<'CardSelect'>
|
||||
Field={Fields.CardSelect}
|
||||
{...(args as ComponentProps<FieldComponents['CardSelect']>)}
|
||||
onChange={onChangeField}
|
||||
/>
|
||||
}
|
||||
fieldType="CardSelect"
|
||||
mainComponent={<CardSelect {...args} onChange={onChange} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const StructureStory: StoryFn<CardSelectProps> = CardSelectTemplateStory.bind({});
|
||||
|
||||
export const LabelOnlyStory: StoryFn<CardSelectProps> = CardSelectTemplateStory.bind({});
|
||||
|
||||
export const ContentOnlyStory: StoryFn<CardSelectProps> = CardSelectTemplateStory.bind({});
|
||||
@@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import { DocTickIcon } from '@fractal-ui/library';
|
||||
import { Title, Controls, Description, Source, Heading, Canvas } from '@storybook/blocks';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { StoryCode } from 'common';
|
||||
import CardSelect from '..';
|
||||
import { SandboxStory, StructureStory, LabelOnlyStory, ContentOnlyStory } from './card-select.examples';
|
||||
import { cardSelectDefaultArgs } from './constants';
|
||||
|
||||
export { SandboxStory, StructureStory, LabelOnlyStory, ContentOnlyStory };
|
||||
SandboxStory.storyName = 'Песочница';
|
||||
SandboxStory.args = { ...cardSelectDefaultArgs, value: '0' };
|
||||
StructureStory.storyName = 'Структура';
|
||||
LabelOnlyStory.storyName = 'Только с лейблами';
|
||||
LabelOnlyStory.args = {
|
||||
...cardSelectDefaultArgs,
|
||||
options: [
|
||||
{
|
||||
icon: DocTickIcon,
|
||||
label: 'Расчётный в рублях',
|
||||
value: '0',
|
||||
},
|
||||
{
|
||||
icon: DocTickIcon,
|
||||
label: 'Расчётный в валюте',
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
icon: DocTickIcon,
|
||||
label: 'Исполнитель госконтракта',
|
||||
value: '2',
|
||||
},
|
||||
{
|
||||
icon: DocTickIcon,
|
||||
label: 'Участник закупок',
|
||||
value: '3',
|
||||
},
|
||||
],
|
||||
};
|
||||
ContentOnlyStory.storyName = 'Только с контентом';
|
||||
ContentOnlyStory.args = { ...cardSelectDefaultArgs, hideAllLabels: true };
|
||||
|
||||
const Docs: React.FC = () => (
|
||||
<>
|
||||
<Title>CardSelect</Title>
|
||||
<Description of={CardSelect} />
|
||||
<Source
|
||||
code={`
|
||||
// импорт компонента CardSelect
|
||||
import { CardSelect } from '@msb/fractal-ui-composites';
|
||||
import { DocTickIcon } from '@fractal-ui/library';
|
||||
|
||||
<CardSelect
|
||||
name="card-select"
|
||||
options={[
|
||||
{
|
||||
icon: DocTickIcon,
|
||||
label: 'Расчётный в рублях',
|
||||
content: <Badge type="success">Расчётный в рублях</Badge>,
|
||||
hideLabel: false,
|
||||
value: '0'
|
||||
},
|
||||
{
|
||||
icon: DocTickIcon,
|
||||
label: 'Расчётный в валюте',
|
||||
content: <Badge type="success">Расчётный в валюте</Badge>,
|
||||
hideLabel: false,
|
||||
value: '1'
|
||||
},
|
||||
{
|
||||
icon: DocTickIcon,
|
||||
label: 'Исполнитель госконтракта',
|
||||
content: <Badge type="success">Исполнитель госконтракта</Badge>,
|
||||
hideLabel: false,
|
||||
value: '2'
|
||||
},
|
||||
{
|
||||
icon: DocTickIcon,
|
||||
label: 'Участник закупок',
|
||||
content: <Badge type="success">Участник закупок</Badge>,
|
||||
hideLabel: false,
|
||||
value: '3'
|
||||
}
|
||||
]}
|
||||
value={'0'}
|
||||
/>
|
||||
`}
|
||||
language="tsx"
|
||||
/>
|
||||
|
||||
<Heading>Структура</Heading>
|
||||
<p>
|
||||
Компонент состоит из нескольких секций, разделённых линиями. Одновременно может быть выбрана только одна секция, либо ни одной (в
|
||||
начальном положении). Каждая секция состоит из компонента Pictogram, лейбла и свободного контента. Текст лейбла обязательно должен
|
||||
быть задан (на XS используется для выбранного значения), контент является не обязателеным. В состоянии Hover добавляется фон и
|
||||
меняется цвет лейбла; в состоянии Active фон, цвет лейбла и иконка галочки, а также меняется расцветка пиктограммы.
|
||||
</p>
|
||||
<Canvas of={StructureStory} />
|
||||
|
||||
<Heading>Только с лейблами </Heading>
|
||||
<Canvas of={LabelOnlyStory} />
|
||||
|
||||
<Heading>Только с контентом </Heading>
|
||||
<Canvas of={ContentOnlyStory} />
|
||||
|
||||
<Heading>Размеры</Heading>
|
||||
<p>Компонент имеет только 1 размер.</p>
|
||||
|
||||
<Heading>Основные состояния</Heading>
|
||||
<p>Default, Hover, Active/Checked, Disabled.</p>
|
||||
|
||||
<Heading>Взаимодействие</Heading>
|
||||
<p>Область ховера/нажатия равна секции.</p>
|
||||
|
||||
<Heading>Адаптивность</Heading>
|
||||
<p>
|
||||
Брейкпоинт XS - на смартфоне компонент превращается в обычный Select.
|
||||
<br />
|
||||
<br />
|
||||
Брейкпоинт XL - применяется широкий вариант компонента.
|
||||
<br />
|
||||
<br />
|
||||
Меняйте размер канвы, чтобы увидеть изменения.
|
||||
</p>
|
||||
|
||||
<Heading>Анимация</Heading>
|
||||
<p>Секции меняют свои состояния фона, лейбла, иконки с фейдом 200 мс.</p>
|
||||
|
||||
<Heading>Доступность с клавиатуры</Heading>
|
||||
<p>Используется стандартный фокус браузера.</p>
|
||||
|
||||
<Heading>Автотесты</Heading>
|
||||
<p>Компонент CardSelect имеет следующие атрибуты, описанные ниже.</p>
|
||||
<h2>
|
||||
<code>[data-name]</code>
|
||||
</h2>
|
||||
<p>
|
||||
Значение атрибута заполняется из свойства <StoryCode>name</StoryCode> компонента.
|
||||
</p>
|
||||
<h2>
|
||||
<code>[data-role]</code>
|
||||
</h2>
|
||||
<p>
|
||||
Все контейнеры имеют значение <StoryCode>data-role=radiogroup</StoryCode>. Все секции имеют значение
|
||||
<StoryCode>data-role=radiobutton</StoryCode>.
|
||||
</p>
|
||||
|
||||
<Heading>Песочница</Heading>
|
||||
<Canvas of={SandboxStory} />
|
||||
|
||||
<Heading>API</Heading>
|
||||
<Controls of={SandboxStory} />
|
||||
</>
|
||||
);
|
||||
|
||||
const StoryMeta: Meta = {
|
||||
title: 'Компоненты формы/CardSelect',
|
||||
component: CardSelect,
|
||||
argTypes: {
|
||||
value: { type: 'string' },
|
||||
onChange: { action: 'onChange' },
|
||||
},
|
||||
args: cardSelectDefaultArgs,
|
||||
parameters: {
|
||||
docs: {
|
||||
page: Docs,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default StoryMeta;
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Badge } from '@msb/fractal-ui-extended';
|
||||
import { DocTickIcon } from '@fractal-ui/library';
|
||||
|
||||
export const cardSelectItemDefaultArgs = {
|
||||
flexBasis: '230px',
|
||||
name: 'card-select',
|
||||
icon: DocTickIcon,
|
||||
label: 'Расчётный в рублях',
|
||||
content: <Badge type="success">Расчётный в рублях</Badge>,
|
||||
value: '0',
|
||||
};
|
||||
|
||||
export const cardSelectDefaultArgs = {
|
||||
name: 'card-select',
|
||||
options: [
|
||||
{
|
||||
icon: DocTickIcon,
|
||||
label: 'Расчётный в рублях',
|
||||
content: <Badge type="success">Расчётный в рублях</Badge>,
|
||||
value: '0',
|
||||
},
|
||||
{
|
||||
icon: DocTickIcon,
|
||||
label: 'Расчётный в валюте',
|
||||
content: <Badge type="success">Расчётный в валюте</Badge>,
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
icon: DocTickIcon,
|
||||
label: 'Исполнитель госконтракта',
|
||||
content: <Badge type="success">Исполнитель госконтракта</Badge>,
|
||||
value: '2',
|
||||
},
|
||||
{
|
||||
icon: DocTickIcon,
|
||||
label: 'Участник закупок',
|
||||
content: <Badge type="success">Участник закупок</Badge>,
|
||||
value: '3',
|
||||
},
|
||||
],
|
||||
label: 'Тип счёта',
|
||||
placeholder: 'Выберите тип счета',
|
||||
disabled: false,
|
||||
hideAllLabels: false,
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { DocTickIcon } from '@fractal-ui/library';
|
||||
import { type BreakpointValue, useBreakpoints } from '@msb/fractal-ui-styling';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import CardSelect from '..';
|
||||
|
||||
jest.mock('../../../../styling/src/hooks/use-breakpoints');
|
||||
|
||||
const options = [
|
||||
{ icon: DocTickIcon, label: 'неизвестно', value: 'unknown' },
|
||||
{ icon: DocTickIcon, label: 'да', value: 'yes' },
|
||||
{ icon: DocTickIcon, label: 'нет', value: 'no' },
|
||||
];
|
||||
|
||||
describe('CardSelect Autotest', () => {
|
||||
const name = 'card-select';
|
||||
const containerQuery = '[data-role="radiogroup"][data-name="card-select"]';
|
||||
const sectionQuery = '[data-role="radiobutton"]';
|
||||
const inputQuery = '[data-role="input"][data-name="card-select"]';
|
||||
|
||||
it('контейнер имеет атрибуты [data-role="radiogroup"] и [data-name="card-select"]', async () => {
|
||||
const { container } = render(<CardSelect name={name} options={options} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(containerQuery)).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('секции имеют атрибут [data-role="radiobutton"]', async () => {
|
||||
const { container } = render(<CardSelect name={name} options={options} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(sectionQuery)).toHaveLength(options.length));
|
||||
});
|
||||
|
||||
it('на брейкпоинте XS компонент имеет атрибуты [data-role="input"] и [data-name="card-select"]', async () => {
|
||||
const mockUseBreakpoints = useBreakpoints as jest.MockedFunction<typeof useBreakpoints> & {
|
||||
mockMatchOne(breakpoint: BreakpointValue): void;
|
||||
};
|
||||
|
||||
mockUseBreakpoints.mockMatchOne('XS');
|
||||
|
||||
const { container } = render(<CardSelect name={name} options={options} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(inputQuery)).toHaveLength(1));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import React from 'react';
|
||||
import { DocTickIcon } from '@fractal-ui/library';
|
||||
import { type BreakpointValue, useBreakpoints } from '@msb/fractal-ui-styling';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { mockObservers } from 'common';
|
||||
import CardSelect from '..';
|
||||
import Mobile from '../mobile';
|
||||
|
||||
jest.mock('../../../../styling/src/hooks/use-breakpoints');
|
||||
|
||||
const options = [
|
||||
{ icon: DocTickIcon, label: 'неизвестно', value: 'unknown', disabled: false },
|
||||
{ icon: DocTickIcon, label: 'да', value: 'yes', content: 'контент-да', disabled: false },
|
||||
{ icon: DocTickIcon, label: 'нет', value: 'no', content: 'контент-нет', disabled: false },
|
||||
];
|
||||
|
||||
const disabledOptions = [
|
||||
{ icon: DocTickIcon, label: 'неизвестно', value: 'unknown', disabled: true },
|
||||
{ icon: DocTickIcon, label: 'да', value: 'yes', disabled: true, hideLabel: true },
|
||||
{ icon: DocTickIcon, label: 'нет', value: 'no', disabled: true, hideLabel: true },
|
||||
];
|
||||
|
||||
const name = 'card-select' as const;
|
||||
const okFilledIcon = '[data-name="OkFilled"]' as const;
|
||||
|
||||
describe('CardSelect', () => {
|
||||
beforeAll(() => jest.useFakeTimers());
|
||||
|
||||
it('проверяет наличие лейбла у опций', async () => {
|
||||
render(<CardSelect name={name} options={options} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('да')).toBeInTheDocument();
|
||||
expect(screen.getByText('нет')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('проверяет отсутствие лейбла у опций, если есть флаг hideAllLabels', async () => {
|
||||
const { container } = render(<CardSelect hideAllLabels name={name} options={options} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll('[data-card-select-item="label"]')).toHaveLength(0));
|
||||
});
|
||||
|
||||
it('проверяет отсутствие лейбла у опций, если hideLabel = true в опциях', async () => {
|
||||
const { container } = render(<CardSelect name={name} options={disabledOptions} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll('[data-card-select-item="label"]')).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('проверяет наличие контента у опций', async () => {
|
||||
render(<CardSelect name={name} options={options} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('контент-да')).toBeInTheDocument();
|
||||
expect(screen.getByText('контент-нет')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('проверяет работу обработчика', async () => {
|
||||
let activeValue = 'yes';
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
activeValue = value;
|
||||
};
|
||||
|
||||
const { getByDisplayValue, rerender } = render(
|
||||
<CardSelect name={name} options={options} value={activeValue} onChange={handleChange} />
|
||||
);
|
||||
|
||||
const yes = getByDisplayValue('yes');
|
||||
const no = getByDisplayValue('no');
|
||||
const unknown = getByDisplayValue('unknown');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(yes).toBeChecked();
|
||||
expect(no).not.toBeChecked();
|
||||
expect(unknown).not.toBeChecked();
|
||||
});
|
||||
|
||||
userEvent.click(no);
|
||||
|
||||
rerender(<CardSelect name={name} options={options} value={activeValue} onChange={handleChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(yes).not.toBeChecked();
|
||||
expect(no).toBeChecked();
|
||||
expect(unknown).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it('проверяет наличие неактивности секций, если есть атрибут disabled', async () => {
|
||||
render(<CardSelect disabled name={name} options={options} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('yes')).toHaveAttribute('disabled');
|
||||
expect(screen.getByDisplayValue('no')).toHaveAttribute('disabled');
|
||||
expect(screen.getByDisplayValue('unknown')).toHaveAttribute('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
it('проверяет наличие неактивности секций, если значение disabled в опциях', async () => {
|
||||
render(<CardSelect name={name} options={disabledOptions} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('no')).toHaveAttribute('disabled');
|
||||
expect(screen.getByDisplayValue('yes')).toHaveAttribute('disabled');
|
||||
expect(screen.getByDisplayValue('unknown')).toHaveAttribute('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
it('на брейкпоинте XS отображается компонент Input', async () => {
|
||||
mockObservers();
|
||||
|
||||
const mockUseBreakpoints = useBreakpoints as jest.MockedFunction<typeof useBreakpoints> & {
|
||||
mockMatchOne(breakpoint: BreakpointValue): void;
|
||||
};
|
||||
|
||||
mockUseBreakpoints.mockMatchOne('XS');
|
||||
|
||||
render(<CardSelect name={name} options={options} value="yes" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('input')).toHaveAttribute('value', 'да'));
|
||||
});
|
||||
|
||||
it('на брейкпоинте XS отображается компонент BottomSheet', async () => {
|
||||
mockObservers();
|
||||
|
||||
const mockUseBreakpoints = useBreakpoints as jest.MockedFunction<typeof useBreakpoints> & {
|
||||
mockMatchOne(breakpoint: BreakpointValue): void;
|
||||
};
|
||||
|
||||
mockUseBreakpoints.mockMatchOne('XS');
|
||||
|
||||
render(<CardSelect name={name} options={options} value="yes" />);
|
||||
|
||||
userEvent.click(screen.getByTestId('input'));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('bottomSheet')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('закрывается BottomSheet на экранах XS при выборе значения', async () => {
|
||||
const { getByTestId, getByDisplayValue, queryByTestId } = render(<Mobile name={name} options={options} value="yes" />);
|
||||
|
||||
userEvent.click(getByTestId('input'));
|
||||
|
||||
await waitFor(() => expect(getByTestId('bottomSheet')).toBeInTheDocument());
|
||||
|
||||
const item = getByDisplayValue('no');
|
||||
|
||||
act(() => {
|
||||
jest.useFakeTimers();
|
||||
userEvent.click(item);
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(queryByTestId('bottomSheet')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('на брейкпоинте XS отсутствует компонент Icon', async () => {
|
||||
mockObservers();
|
||||
|
||||
const mockUseBreakpoints = useBreakpoints as jest.MockedFunction<typeof useBreakpoints> & {
|
||||
mockMatchOne(breakpoint: BreakpointValue): void;
|
||||
};
|
||||
|
||||
mockUseBreakpoints.mockMatchOne('XS');
|
||||
|
||||
const { container } = render(<CardSelect name={name} options={options} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll(okFilledIcon)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('не на брейкпоинте XS присутствует компонент Icon', async () => {
|
||||
mockObservers();
|
||||
|
||||
const mockUseBreakpoints = useBreakpoints as jest.MockedFunction<typeof useBreakpoints> & {
|
||||
mockMatchOne(breakpoint: BreakpointValue): void;
|
||||
};
|
||||
|
||||
mockUseBreakpoints.mockMatchOne('S');
|
||||
|
||||
const { container } = render(<CardSelect name={name} options={options} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(okFilledIcon)).toHaveLength(3));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { getBackground, getGradient, getColor } from '../utils';
|
||||
|
||||
const BG_PRIMARY = 'bg.primary';
|
||||
const BG_FOUR = 'bg.four';
|
||||
|
||||
describe('CardSelect utils', () => {
|
||||
it('фон принимает значение "bg.four", если disabled = true, checked = true и isBreakpointXS = true', () => {
|
||||
expect(getBackground(true, true, true)).toBe(BG_FOUR);
|
||||
});
|
||||
|
||||
it('фон принимает значение "bg.primary", если disabled = true, checked = true и isBreakpointXS = false', () => {
|
||||
expect(getBackground(true, true)).toBe(BG_PRIMARY);
|
||||
});
|
||||
|
||||
it('фон принимает значение "bg.four", если disabled = true, checked = false и isBreakpointXS = true', () => {
|
||||
expect(getBackground(true, false, true)).toBe(BG_FOUR);
|
||||
});
|
||||
|
||||
it('фон принимает значение "bg.primary", если disabled = true, checked = false и isBreakpointXS = false', () => {
|
||||
expect(getBackground(true, false)).toBe(BG_PRIMARY);
|
||||
});
|
||||
|
||||
it('фон принимает значение "bg.four", если disabled = false, checked = false и isBreakpointXS = true', () => {
|
||||
expect(getBackground(false, false, true)).toBe(BG_FOUR);
|
||||
});
|
||||
|
||||
it('фон принимает значение "bg.primary", если disabled = false, checked = false и isBreakpointXS = false', () => {
|
||||
expect(getBackground(false, false)).toBe(BG_PRIMARY);
|
||||
});
|
||||
|
||||
it('фон принимает значение "bg.selected", если disabled = false и checked = true', () => {
|
||||
expect(getBackground(false, true)).toBe('bg.selected');
|
||||
});
|
||||
|
||||
it('градиент принимает значение "smoke", если disabled = true и checked = true', () => {
|
||||
expect(getGradient(true, true)).toBe('smoke');
|
||||
});
|
||||
|
||||
it('градиент принимает значение "smoke", если disabled = true и checked = false', () => {
|
||||
expect(getGradient(true, false)).toBe('smoke');
|
||||
});
|
||||
|
||||
it('градиент принимает значение "sunsetSky", если disabled = false и checked = true', () => {
|
||||
expect(getGradient(false, true)).toBe('sunsetSky');
|
||||
});
|
||||
|
||||
it('градиент принимает значение "sapphire", если disabled = false и checked = false', () => {
|
||||
expect(getGradient(false, false)).toBe('sapphire');
|
||||
});
|
||||
|
||||
it('цвет принимает значение "control.disabled.typo", если disabled = true и checked = true', () => {
|
||||
expect(getColor(true, true)).toBe('control.disabled.typo');
|
||||
});
|
||||
|
||||
it('цвет принимает значение "control.disabled.typo", если disabled = true и checked = false', () => {
|
||||
expect(getColor(true, false)).toBe('control.disabled.typo');
|
||||
});
|
||||
|
||||
it('цвет принимает значение "text.primary", если disabled = false и checked = true', () => {
|
||||
expect(getColor(false, true)).toBe('text.primary');
|
||||
});
|
||||
|
||||
it('цвет принимает значение "text.secondary", если disabled = false и checked = false', () => {
|
||||
expect(getColor(false, false)).toBe('text.secondary');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { ROLE } from '@msb/fractal-ui-core';
|
||||
import type { PictogramProps } from '@fractal-ui/library';
|
||||
import { Pictogram } from '@fractal-ui/library';
|
||||
import { Text, useBreakpoints, Wrapper } from '@msb/fractal-ui-styling';
|
||||
import { Section, Icon, Input } from './styled';
|
||||
import type { CardSelectItemProps } from './types';
|
||||
import { getBackground, getGradient, getColor } from './utils';
|
||||
|
||||
/** Соответствие размеров для пиктограмм. */
|
||||
const PICTOGRAM_SIZES: Record<Exclude<CardSelectItemProps['size'], undefined>, PictogramProps['size']> = {
|
||||
S: 'M',
|
||||
M: 'L',
|
||||
L: 'XL',
|
||||
};
|
||||
|
||||
/**
|
||||
* Компонент представляет собой секцию компонента CardSelect.
|
||||
*/
|
||||
const CardSelectItem: React.FC<CardSelectItemProps> = ({
|
||||
flexBasis,
|
||||
name,
|
||||
value,
|
||||
checked,
|
||||
icon,
|
||||
size = 'L',
|
||||
label,
|
||||
content,
|
||||
disabled,
|
||||
hideLabel = false,
|
||||
onChange,
|
||||
// TODO заменить на токен, когда будут добавлены константные для обоих тем цвета
|
||||
iconColor = 'white',
|
||||
}) => {
|
||||
const { XS, XL } = useBreakpoints();
|
||||
|
||||
const background = getBackground(disabled, checked, XS);
|
||||
const gradient = getGradient(disabled, checked);
|
||||
const color = getColor(disabled, checked);
|
||||
const opacity = checked ? 1 : 0;
|
||||
const hasHover = !(disabled || checked);
|
||||
|
||||
return (
|
||||
<Section bg={background} data-role={ROLE.RADIOBUTTON} flexBasis={flexBasis} hasHover={hasHover}>
|
||||
<Input checked={checked} disabled={disabled} name={name} type="radio" value={value} onChange={onChange} />
|
||||
<Wrapper display="flex" gap="cardSelect.gap">
|
||||
<Pictogram color={iconColor} gradient={gradient} icon={icon} size={PICTOGRAM_SIZES[size]} />
|
||||
{!hideLabel && (XS || XL) && (
|
||||
<Text.P2 alignSelf="center" color={color} data-card-select-item="label" transition="color 0.2s" wordBreak="break-word">
|
||||
{label}
|
||||
</Text.P2>
|
||||
)}
|
||||
<Wrapper alignItems={{ XS: 'center', S: 'normal' }} display="flex" ml="auto">
|
||||
<Icon color="text.accentBrand" opacity={opacity} size={size} />
|
||||
</Wrapper>
|
||||
</Wrapper>
|
||||
{!hideLabel && !XS && !XL && (
|
||||
<Text.P2 color={color} data-card-select-item="label" transition="color 0.2s" wordBreak="break-word">
|
||||
{label}
|
||||
</Text.P2>
|
||||
)}
|
||||
{content}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
CardSelectItem.displayName = 'CardSelectItem';
|
||||
|
||||
export default CardSelectItem;
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { Fragment, useCallback } from 'react';
|
||||
import { Divider, ROLE } from '@msb/fractal-ui-core';
|
||||
import { Wrapper } from '@msb/fractal-ui-styling';
|
||||
import CardSelectItem from './card-select-item';
|
||||
import type { DesktopProps } from './types';
|
||||
|
||||
const borderStyle = { borderColor: 'bg.ghost16', borderStyle: 'solid', borderWidth: 'cardSelect', borderRadius: 'cardSelect' };
|
||||
|
||||
/**
|
||||
* Компонент Desktop.
|
||||
*/
|
||||
const Desktop: React.FC<DesktopProps> = ({ name, options, value, disabled, hideAllLabels, onChange }) => {
|
||||
const handleChange = useCallback(
|
||||
event => {
|
||||
onChange?.(event.target.value, event);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper data-name={name} data-role={ROLE.RADIOGROUP} display="flex" padding="cardSelect.paddingContainer" {...borderStyle}>
|
||||
{options.map(({ iconColor, icon, label, hideLabel, content, ...option }, index) => {
|
||||
const flexBasis = `${100 / options.length}%`;
|
||||
const isLastItem = index === options.length - 1;
|
||||
|
||||
return (
|
||||
<Fragment key={option.value}>
|
||||
<CardSelectItem
|
||||
checked={value === option.value}
|
||||
content={content}
|
||||
disabled={disabled || option.disabled}
|
||||
flexBasis={flexBasis}
|
||||
hideLabel={hideAllLabels || hideLabel}
|
||||
icon={icon}
|
||||
iconColor={iconColor}
|
||||
label={label}
|
||||
name={name}
|
||||
value={option.value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{!isLastItem && (
|
||||
<Wrapper px="cardSelect.dividerWrap.paddingX" py="cardSelect.dividerWrap.paddingY">
|
||||
<Divider vertical m="0" />
|
||||
</Wrapper>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
Desktop.displayName = 'Desktop';
|
||||
|
||||
export default Desktop;
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { BreakPoint, Responsive } from '@msb/fractal-ui-styling';
|
||||
import Desktop from './desktop';
|
||||
import Mobile from './mobile';
|
||||
import type { CardSelectProps } from './types';
|
||||
|
||||
/**
|
||||
* Компонент представляет собой визуально более интересный способ выбора из небольшого количества вариантов.
|
||||
* От выбора зависит остальной контент на странице.
|
||||
* Может использоваться в качестве одного из уровней локальной навигации (как табы).
|
||||
*
|
||||
* @see https://www.figma.com/file/CizcXMEqxBSENC0hXJATi3/Fractal-UI-Kit?type=design&node-id=21303-215675
|
||||
*/
|
||||
const CardSelect: React.FC<CardSelectProps> = ({ label, placeholder, ...props }) => (
|
||||
<Responsive>
|
||||
<BreakPoint>
|
||||
<Desktop {...props} />
|
||||
</BreakPoint>
|
||||
<BreakPoint at="XS">
|
||||
<Mobile {...props} label={label} placeholder={placeholder} />
|
||||
</BreakPoint>
|
||||
</Responsive>
|
||||
);
|
||||
|
||||
CardSelect.displayName = 'CardSelect';
|
||||
|
||||
export default CardSelect;
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { DownIcon } from '@fractal-ui/library';
|
||||
import { BottomSheet } from '@msb/fractal-ui-overlays';
|
||||
import Input from '../input';
|
||||
import { INPUT_ICON_SIZES } from '../input/helpers';
|
||||
import { StyledIcon } from '../select/styled';
|
||||
import CardSelectItem from './card-select-item';
|
||||
import { MobileContainer } from './styled';
|
||||
import type { CardSelectProps } from './types';
|
||||
|
||||
/**
|
||||
* Компонент Mobile.
|
||||
*/
|
||||
const Mobile: React.FC<CardSelectProps> = ({ name, options, label, placeholder, value, disabled, hideAllLabels, onChange }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const inputValue = options.find(option => value === option.value)?.label || '';
|
||||
|
||||
const openBottomSheet = useCallback(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeBottomSheet = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
event => {
|
||||
onChange?.(event.target.value, event);
|
||||
closeBottomSheet();
|
||||
},
|
||||
[onChange, closeBottomSheet]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
rightIcon={
|
||||
<StyledIcon data-action="open" data-name={name} isOpen={isOpen}>
|
||||
<DownIcon size={INPUT_ICON_SIZES.M} />
|
||||
</StyledIcon>
|
||||
}
|
||||
rightIconClick={openBottomSheet}
|
||||
size="M"
|
||||
value={inputValue}
|
||||
onClick={openBottomSheet}
|
||||
/>
|
||||
<BottomSheet header={label} isOpen={isOpen} onClose={closeBottomSheet}>
|
||||
<MobileContainer>
|
||||
{options.map(({ icon, hideLabel, content, ...option }) => (
|
||||
<CardSelectItem
|
||||
key={option.value}
|
||||
checked={value === option.value}
|
||||
content={content}
|
||||
disabled={disabled || option.disabled}
|
||||
hideLabel={hideAllLabels || hideLabel}
|
||||
icon={icon}
|
||||
label={option.label}
|
||||
name={name}
|
||||
size="S"
|
||||
value={option.value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
))}
|
||||
</MobileContainer>
|
||||
</BottomSheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Mobile.displayName = 'Mobile';
|
||||
|
||||
export default Mobile;
|
||||
@@ -0,0 +1,65 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { OkFilledIcon } from '@fractal-ui/library';
|
||||
import styledCss from '@styled-system/css';
|
||||
import type { BackgroundColorProps, FlexBasisProps, OpacityProps } from 'styled-system';
|
||||
import { compose, color, flexBasis, opacity } from 'styled-system';
|
||||
|
||||
/**
|
||||
* Контейнер компонента CardSelect при брейкпоинте XS.
|
||||
*/
|
||||
export const MobileContainer = styled.div(
|
||||
styledCss({
|
||||
px: 'cardSelect.mobileContainer.paddingX',
|
||||
'& > div': {
|
||||
mb: 'cardSelect.mobileContainer.marginBottom',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Секция компонента CardSelect.
|
||||
*/
|
||||
export const Section = styled.div<BackgroundColorProps & FlexBasisProps & { hasHover?: boolean }>(
|
||||
({ hasHover }) =>
|
||||
styledCss({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'cardSelect.gap',
|
||||
padding: 'cardSelect.paddingSection',
|
||||
borderRadius: 'M',
|
||||
transition: 'background-color 0.2s',
|
||||
':hover': {
|
||||
backgroundColor: hasHover ? 'bg.hover' : undefined,
|
||||
'& [data-card-select-item=label]': {
|
||||
color: hasHover ? 'text.primary' : undefined,
|
||||
},
|
||||
},
|
||||
}),
|
||||
compose(color, flexBasis)
|
||||
);
|
||||
|
||||
/**
|
||||
* Иконка компонента CardSelectItem.
|
||||
*/
|
||||
export const Icon = styled(OkFilledIcon)<OpacityProps>`
|
||||
transition: opacity 0.2s;
|
||||
${opacity}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Инпут компонента CardSelect.
|
||||
*/
|
||||
export const Input = styled.input(({ disabled }) =>
|
||||
styledCss({
|
||||
position: 'absolute',
|
||||
top: 'cardSelect.input.top',
|
||||
left: 'cardSelect.input.left',
|
||||
zIndex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
margin: 'cardSelect.input.margin',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
opacity: 0,
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ReactNode, SyntheticEvent } from 'react';
|
||||
import type { PictogramProps } from '@fractal-ui/library';
|
||||
import type { CheckedProps, DisabledProps, SizeS, SizeM, SizeL } from '@msb/fractal-ui-styling';
|
||||
import type { FlexBasisProps } from 'styled-system';
|
||||
|
||||
/**
|
||||
* Значение опции/секции.
|
||||
*/
|
||||
export type ValueType = number | string;
|
||||
|
||||
/**
|
||||
* Опция секции.
|
||||
*/
|
||||
export interface Option extends DisabledProps, Pick<PictogramProps, 'icon'> {
|
||||
/** Цвет иконки.
|
||||
*
|
||||
* @default white
|
||||
*/
|
||||
iconColor?: PictogramProps['color'];
|
||||
/**
|
||||
* Текст лейбла.
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Свободный контент.
|
||||
*/
|
||||
content?: ReactNode;
|
||||
/**
|
||||
* Состояние скрытия/отображения лейбла.
|
||||
*/
|
||||
hideLabel?: boolean;
|
||||
/**
|
||||
* Значение опции/секции.
|
||||
*/
|
||||
value: ValueType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пропсы компонента CardSelectItem.
|
||||
*/
|
||||
export interface CardSelectItemProps extends CheckedProps, Option {
|
||||
/**
|
||||
* Ширина.
|
||||
*/
|
||||
flexBasis?: FlexBasisProps['flexBasis'];
|
||||
/**
|
||||
* Имя компонента (группы радиокнопки).
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Размер компонента.
|
||||
*/
|
||||
size?: SizeL | SizeM | SizeS;
|
||||
/**
|
||||
* Обработчик события изменения значения секции.
|
||||
*/
|
||||
onChange(event: SyntheticEvent): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пропсы компонента CardSelect.
|
||||
*/
|
||||
export interface CardSelectProps extends DisabledProps {
|
||||
/**
|
||||
* Имя компонента на форме.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Опции для отображения.
|
||||
*/
|
||||
options: Option[];
|
||||
/**
|
||||
* Лейбл при брейкпоинте XS.
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* Подсказка при отсутствии значения при брейкпоинте XS.
|
||||
*/
|
||||
placeholder?: string;
|
||||
/**
|
||||
* Значение выбранной опции/секции.
|
||||
*/
|
||||
value?: ValueType;
|
||||
/**
|
||||
* Состояние скрытия/отображения лейблов у всех секций.
|
||||
*/
|
||||
hideAllLabels?: boolean;
|
||||
/**
|
||||
* Обработчик события изменения значения секции.
|
||||
*/
|
||||
onChange?(value: ValueType, event?: SyntheticEvent): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пропсы компонента Desktop.
|
||||
*/
|
||||
export type DesktopProps = Omit<CardSelectProps, 'label' | 'placeholder'>;
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { CheckedProps, DisabledProps } from '@msb/fractal-ui-styling';
|
||||
|
||||
/**
|
||||
* Получение цвета фона секции.
|
||||
*/
|
||||
export const getBackground = (disabled: DisabledProps['disabled'], checked: CheckedProps['checked'], isBreakpointXS: boolean = false) => {
|
||||
const initialBackground = isBreakpointXS ? 'bg.four' : 'bg.primary';
|
||||
|
||||
if (disabled || !checked) return initialBackground;
|
||||
|
||||
return 'bg.selected';
|
||||
};
|
||||
|
||||
/**
|
||||
* Получение градиента пиктограммы.
|
||||
*/
|
||||
export const getGradient = (disabled: DisabledProps['disabled'], checked: CheckedProps['checked']) => {
|
||||
if (disabled) {
|
||||
return 'smoke';
|
||||
}
|
||||
|
||||
return checked ? 'sunsetSky' : 'sapphire';
|
||||
};
|
||||
|
||||
/**
|
||||
* Получение цвета лейбла.
|
||||
*/
|
||||
export const getColor = (disabled: DisabledProps['disabled'], checked: CheckedProps['checked']) => {
|
||||
if (disabled) {
|
||||
return 'control.disabled.typo';
|
||||
}
|
||||
|
||||
return checked ? 'text.primary' : 'text.secondary';
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import { Title, Controls, Subheading, Description, Source, Heading, Canvas } from '@storybook/blocks';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { iconsOptions, StoryCode } from 'common';
|
||||
import Checkbox from '..';
|
||||
import { DisabledStory, ErrorStory, IndeterminateStory, LinkStory, SandboxStory, SizeStory, UsingStory } from './examples';
|
||||
|
||||
export { DisabledStory, ErrorStory, IndeterminateStory, LinkStory, SandboxStory, SizeStory, UsingStory };
|
||||
SandboxStory.storyName = 'Песочница';
|
||||
SandboxStory.args = {
|
||||
value: true,
|
||||
label: 'Чекбокс',
|
||||
tooltip: 'Tooltip',
|
||||
tooltipIcon: undefined,
|
||||
disabled: false,
|
||||
isIndeterminate: false,
|
||||
hasError: false,
|
||||
name: 'checkbox-name',
|
||||
tabIndex: 1,
|
||||
size: 'M',
|
||||
errorText: 'Необходимо подтвердить свое согласие',
|
||||
};
|
||||
SandboxStory.argTypes = {
|
||||
tooltipIcon: {
|
||||
control: { type: 'select' },
|
||||
options: ['', ...iconsOptions],
|
||||
},
|
||||
};
|
||||
SizeStory.storyName = 'Размеры';
|
||||
LinkStory.storyName = 'Ссылки';
|
||||
ErrorStory.storyName = 'Error';
|
||||
DisabledStory.storyName = 'Disabled';
|
||||
IndeterminateStory.storyName = 'Indeterminate';
|
||||
UsingStory.storyName = 'Варианты использования';
|
||||
|
||||
const Docs: React.FC = () => (
|
||||
<>
|
||||
<Title>Checkbox</Title>
|
||||
<Description of={Checkbox} />
|
||||
<Source
|
||||
code={`
|
||||
/** Импорт компонента Checkbox. */
|
||||
import { Checkbox } from '@msb/fractal-ui-composites';
|
||||
|
||||
const [value, setValue] = useState(false);
|
||||
|
||||
<Checkbox label="Чекбокс" name="checkbox-name" size="M" value={value} onChange={setValue} />;
|
||||
`}
|
||||
language="tsx"
|
||||
/>
|
||||
|
||||
<Heading>Внешний вид</Heading>
|
||||
<Subheading>Size</Subheading>
|
||||
<p>
|
||||
Размер указывается пропсом <StoryCode>size</StoryCode>, по умолчанию установлено в <StoryCode>M</StoryCode>.
|
||||
</p>
|
||||
<Canvas of={SizeStory} />
|
||||
|
||||
<Subheading>Label</Subheading>
|
||||
<p>
|
||||
Пропс <StoryCode>label</StoryCode> может содержать ссылку.
|
||||
</p>
|
||||
<Canvas of={LinkStory} />
|
||||
|
||||
<Heading>Состояния</Heading>
|
||||
<p>
|
||||
Состояние <StoryCode>error</StoryCode> задается пропсом <StoryCode>hasError</StoryCode>.
|
||||
</p>
|
||||
<Canvas of={ErrorStory} />
|
||||
<p>
|
||||
Состояние <StoryCode>disabled</StoryCode> задается пропсом <StoryCode>disabled</StoryCode>.
|
||||
</p>
|
||||
<Canvas of={DisabledStory} />
|
||||
<p>
|
||||
Состояние <StoryCode>indeterminate</StoryCode> задается пропсом <StoryCode>isIndeterminate</StoryCode>.
|
||||
</p>
|
||||
<Canvas of={IndeterminateStory} />
|
||||
|
||||
<Heading>Варианты использования</Heading>
|
||||
<p>
|
||||
Для предотвращения изменения состояния компонента <StoryCode>Checkbox</StoryCode> при нажатии или тапе по иконке для отображения
|
||||
компонента <StoryCode>Tooltip</StoryCode>, необходимо в компонент <StoryCode>Checkbox</StoryCode> передать свойство{' '}
|
||||
<StoryCode>preventClickTooltip</StoryCode> со значением <StoryCode>true</StoryCode>.
|
||||
</p>
|
||||
<Canvas of={UsingStory} />
|
||||
|
||||
<Heading>Автотесты</Heading>
|
||||
<p>
|
||||
Компонент <StoryCode>Checkbox</StoryCode> имеет следующие атрибуты, описанные ниже.
|
||||
</p>
|
||||
<p>Все атрибуты всегда присутствуют в html-разметке. Значение атрибутов соответствуют значению пропсов.</p>
|
||||
<Subheading>[data-role]</Subheading>
|
||||
<p>
|
||||
Все чекбоксы имеют значение <StoryCode>data-role=checkbox</StoryCode>.
|
||||
</p>
|
||||
<p>
|
||||
Текстовые компоненты чекбоксов имеют значение <StoryCode>data-role=title</StoryCode>.
|
||||
</p>
|
||||
<p>
|
||||
Текстовые компоненты ошибок чекбоксов имеют значение <StoryCode>data-role=error</StoryCode>.
|
||||
</p>
|
||||
<Subheading>[data-name]</Subheading>
|
||||
<p>
|
||||
Значение атрибута заполняется из свойства <StoryCode>name</StoryCode> компонента.
|
||||
</p>
|
||||
<Subheading>[data-checked]</Subheading>
|
||||
<p>
|
||||
Значение атрибута заполняется из свойства <StoryCode>value</StoryCode> компонента.
|
||||
</p>
|
||||
<Subheading>[data-indeterminate]</Subheading>
|
||||
<p>
|
||||
Значение атрибута заполняется из свойства <StoryCode>isIndeterminate</StoryCode> компонента.
|
||||
</p>
|
||||
|
||||
<Heading>Песочница</Heading>
|
||||
<Canvas of={SandboxStory} />
|
||||
|
||||
<Heading>API</Heading>
|
||||
<Controls of={SandboxStory} />
|
||||
</>
|
||||
);
|
||||
|
||||
const StoryMeta: Meta = {
|
||||
title: 'Компоненты формы/Checkbox',
|
||||
component: Checkbox,
|
||||
parameters: {
|
||||
docs: {
|
||||
page: Docs,
|
||||
source: {
|
||||
language: 'tsx',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default StoryMeta;
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { useState } from 'react';
|
||||
import Checkbox from '..';
|
||||
import type { CheckboxProps } from '../types';
|
||||
|
||||
const labelContentColor = 'text.secondary';
|
||||
|
||||
export const labelContent = (
|
||||
<div>
|
||||
<p style={{ margin: 0 }}>Подтверждаем согласие с тем, что:</p>
|
||||
<ul style={{ margin: '8px 0 0', padding: 0, listStylePosition: 'inside' }}>
|
||||
<li style={{ marginBottom: 8, color: labelContentColor }}>
|
||||
в случае отказа клиента от открытия счёта в банке по каким-либо причинам, документы, предоставленные для открытия счёта, могут быть
|
||||
истребованы в письменной форме и подлежат возврату клиенту или его представителю (на основании доверенности) под роспись на
|
||||
указанном письме;
|
||||
</li>
|
||||
<li style={{ marginBottom: 8, color: labelContentColor }}>
|
||||
в случае, если по каким-либо причинам счёт не открыт, и клиентом в течение одного года с даты подачи документов в банк документы не
|
||||
истребованы, банк оставляет за собой право уничтожения указанных документов в установленном в банке порядке;
|
||||
</li>
|
||||
<li style={{ marginBottom: 8, color: labelContentColor }}>
|
||||
в случае, если по каким-либо причинам счёт не открыт, комиссия, уплаченная банку при предоставлении документов на открытие счёта,
|
||||
возврату не подлежит.{' '}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CheckboxTemplate = ({ value, ...props }: Partial<CheckboxProps>) => {
|
||||
const [valueState, setValueState] = useState(value);
|
||||
|
||||
return <Checkbox {...props} name="checkbox-name" value={valueState} onChange={v => setValueState(v)} />;
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import React from 'react';
|
||||
import { Link } from '@msb/fractal-ui-core';
|
||||
import type { FieldComponents } from '@msb/fractal-ui-form';
|
||||
import { Fields } from '@msb/fractal-ui-form';
|
||||
import * as Icons from '@fractal-ui/library';
|
||||
import type { IconName } from '@fractal-ui/library';
|
||||
import { useArgs } from '@storybook/preview-api';
|
||||
import type { StoryFn } from '@storybook/react';
|
||||
import { SandboxField, SandboxStoryForm } from 'common';
|
||||
import Checkbox from '..';
|
||||
import type { CheckboxProps } from '../types';
|
||||
import { CheckboxTemplate, labelContent } from './components';
|
||||
|
||||
export const SandboxStory: StoryFn<Omit<CheckboxProps, 'tooltipIcon'> & { tooltipIcon?: '' | `${IconName}Icon` }> = ({
|
||||
onChange,
|
||||
tooltipIcon,
|
||||
...props
|
||||
}) => {
|
||||
const [_, updateArgs] = useArgs();
|
||||
|
||||
const handelOnChange: CheckboxProps['onChange'] = (value, event) => {
|
||||
onChange?.(value, event);
|
||||
updateArgs({ ...props, value });
|
||||
};
|
||||
|
||||
const onChangeField: ComponentProps<FieldComponents['Checkbox']>['onChange'] = ({ value, event }) =>
|
||||
handelOnChange(Boolean(value), event);
|
||||
|
||||
const Icon = tooltipIcon ? Icons[tooltipIcon] : undefined;
|
||||
|
||||
return (
|
||||
<SandboxStoryForm
|
||||
fieldComponent={
|
||||
<SandboxField<'Checkbox'>
|
||||
Field={Fields.Checkbox}
|
||||
{...(props as ComponentProps<FieldComponents['Checkbox']>)}
|
||||
tooltipIcon={Icon}
|
||||
onChange={onChangeField}
|
||||
/>
|
||||
}
|
||||
fieldType="Checkbox"
|
||||
mainComponent={<Checkbox {...props} tooltipIcon={Icon} onChange={handelOnChange} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const SizeStory: StoryFn = () => (
|
||||
<>
|
||||
<CheckboxTemplate label="Чекбокс" />
|
||||
<br />
|
||||
<CheckboxTemplate value label="Чекбокс" />
|
||||
<br />
|
||||
<CheckboxTemplate isIndeterminate value label="Чекбокс" />
|
||||
<br />
|
||||
<CheckboxTemplate disabled value label="Чекбокс" />
|
||||
<br />
|
||||
<br />
|
||||
<CheckboxTemplate label="Чекбокс" size="S" />
|
||||
<br />
|
||||
<CheckboxTemplate value label="Чекбокс" size="S" />
|
||||
<br />
|
||||
<CheckboxTemplate isIndeterminate value label="Чекбокс" size="S" />
|
||||
<br />
|
||||
<CheckboxTemplate disabled value label="Чекбокс" size="S" />
|
||||
</>
|
||||
);
|
||||
|
||||
export const LinkStory: StoryFn = () => (
|
||||
<>
|
||||
<CheckboxTemplate
|
||||
label={
|
||||
<span>
|
||||
Чекбокс <Link size="L">ссылка</Link>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<br />
|
||||
<CheckboxTemplate
|
||||
value
|
||||
label={
|
||||
<span>
|
||||
Чекбокс <Link size="L">ссылка</Link>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<br />
|
||||
<CheckboxTemplate
|
||||
isIndeterminate
|
||||
value
|
||||
label={
|
||||
<span>
|
||||
Чекбокс <Link size="L">ссылка</Link>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<br />
|
||||
<CheckboxTemplate
|
||||
disabled
|
||||
value
|
||||
label={
|
||||
<span>
|
||||
Чекбокс{' '}
|
||||
<Link disabled size="L">
|
||||
ссылка
|
||||
</Link>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
export const ErrorStory: StoryFn = () => (
|
||||
<>
|
||||
<CheckboxTemplate hasError label="Чекбокс" />
|
||||
<br />
|
||||
<CheckboxTemplate hasError value label="Чекбокс" />
|
||||
<br />
|
||||
<CheckboxTemplate hasError isIndeterminate label="Чекбокс" />
|
||||
<br />
|
||||
<CheckboxTemplate disabled hasError value label="Чекбокс" />
|
||||
<br />
|
||||
<br />
|
||||
<CheckboxTemplate hasError errorText="Необходимо подтвердить свое согласие" label="Чекбокс" />
|
||||
</>
|
||||
);
|
||||
|
||||
export const DisabledStory: StoryFn = () => (
|
||||
<>
|
||||
<CheckboxTemplate disabled label="Чекбокс" />
|
||||
<br />
|
||||
<CheckboxTemplate disabled value label="Чекбокс" />
|
||||
<br />
|
||||
<CheckboxTemplate disabled isIndeterminate label="Чекбокс" />
|
||||
</>
|
||||
);
|
||||
|
||||
export const IndeterminateStory: StoryFn = () => (
|
||||
<>
|
||||
<CheckboxTemplate isIndeterminate label="Чекбокс" />
|
||||
<br />
|
||||
<CheckboxTemplate disabled isIndeterminate label="Чекбокс" />
|
||||
</>
|
||||
);
|
||||
|
||||
export const UsingStory: StoryFn = () => (
|
||||
<>
|
||||
<p>
|
||||
<b>С подсказкой:</b>
|
||||
</p>
|
||||
<CheckboxTemplate label="Чекбокс" tooltip="Подсказка" />
|
||||
<br />
|
||||
<p>
|
||||
<b>Без подсказки:</b>
|
||||
</p>
|
||||
<CheckboxTemplate label="Чекбокс" />
|
||||
<br />
|
||||
<p>
|
||||
<b>Многострочный лейбл:</b>
|
||||
</p>
|
||||
<CheckboxTemplate label="В случае отказа клиента от открытия счёта в банке по каким-либо причинам, документы, предоставленные для открытия счёта, могут быть истребованы в письменной форме и подлежат возврату клиенту или его представителю (на основании доверенности) под роспись на указанном письме. В случае отказа клиента от открытия счёта в банке по каким-либо причинам, документы, предоставленные для открытия счёта, могут быть истребованы в письменной форме и подлежат возврату клиенту или его представителю (на основании доверенности) под роспись на указанном письме" />
|
||||
<p>
|
||||
<b>С ссылкой в лейбле:</b>
|
||||
</p>
|
||||
<CheckboxTemplate
|
||||
label={
|
||||
<>
|
||||
Перейдите по{' '}
|
||||
<Link href="https://www.gazprombank.ru/" rel="noreferrer" target="_blank">
|
||||
ссылке
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<p>
|
||||
<b>С разметкой в лейбле:</b>
|
||||
</p>
|
||||
<CheckboxTemplate label={labelContent} />
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import Checkbox from '..';
|
||||
|
||||
describe('Checkbox Autotest', () => {
|
||||
const size = 'M';
|
||||
const value = true;
|
||||
const name = 'checkbox-name';
|
||||
const label = 'checkbox-label';
|
||||
const errorText = 'checkbox-error';
|
||||
const inputQuery = '[data-role="checkbox"][data-name="checkbox-name"]';
|
||||
const labelQuery = '[data-role="title"][data-name="checkbox-name"]';
|
||||
const errorQuery = '[data-role="error"][data-name="checkbox-name"]';
|
||||
|
||||
it('имеет атрибуты [data-role="checkbox"] и [data-name="checkbox-name"]', async () => {
|
||||
const { container } = render(<Checkbox name={name} size={size} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(inputQuery)).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('имеет атрибут [data-checked="true"]', async () => {
|
||||
const { container } = render(<Checkbox name={name} size={size} value={value} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(`${inputQuery}[data-checked="true"]`)).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('имеет атрибут [data-indeterminate="true"]', async () => {
|
||||
const { container } = render(<Checkbox isIndeterminate name={name} size={size} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(`${inputQuery}[data-indeterminate="true"]`)).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('имеет атрибуты [data-role="title"] и [data-name="checkbox-name"]', async () => {
|
||||
const { container } = render(<Checkbox label={label} name={name} size={size} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(labelQuery)).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('имеет атрибуты [data-role="error"] и [data-name="checkbox-name"]', async () => {
|
||||
const { container } = render(<Checkbox hasError errorText={errorText} name={name} size={size} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(errorQuery)).toHaveLength(1));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { InfoIcon } from '@fractal-ui/library';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { mockObservers } from 'common';
|
||||
import Checkbox from '..';
|
||||
import type { BaseCheckboxProps } from '../types';
|
||||
|
||||
const onChangeMock = jest.fn();
|
||||
|
||||
const props: BaseCheckboxProps = {
|
||||
name: 'checkbox-name',
|
||||
value: false,
|
||||
onChange: onChangeMock,
|
||||
size: 'M',
|
||||
};
|
||||
|
||||
const label = 'checkbox-label';
|
||||
const tickIcon = '[data-name=Tick]';
|
||||
const minusIcon = '[data-name=Minus]';
|
||||
|
||||
jest.mock('../../../../styling/src/hooks/use-breakpoints');
|
||||
|
||||
describe('Checkbox', () => {
|
||||
beforeEach(() => {
|
||||
mockObservers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('отображается label', () => {
|
||||
const { rerender } = render(<Checkbox {...props} />);
|
||||
|
||||
expect(screen.queryByText(label)).toBeNull();
|
||||
|
||||
rerender(<Checkbox {...props} label={label} />);
|
||||
|
||||
expect(screen.queryByText(label)).toBeDefined();
|
||||
});
|
||||
|
||||
test('отображается иконка checked', async () => {
|
||||
const { container, rerender } = render(<Checkbox {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(tickIcon)).not.toBeInTheDocument();
|
||||
expect(container.querySelector(minusIcon)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(<Checkbox {...props} value />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(tickIcon)).toBeInTheDocument();
|
||||
expect(container.querySelector(minusIcon)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('отображается иконка minus', async () => {
|
||||
const { container, rerender } = render(<Checkbox {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(tickIcon)).not.toBeInTheDocument();
|
||||
expect(container.querySelector(minusIcon)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(<Checkbox {...props} isIndeterminate />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(tickIcon)).not.toBeInTheDocument();
|
||||
expect(container.querySelector(minusIcon)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('присутствует аттрибут name у чекбокса', () => {
|
||||
const { rerender } = render(<Checkbox {...props} />);
|
||||
|
||||
expect(screen.getByRole('checkbox')).toHaveAttribute('name', 'checkbox-name');
|
||||
|
||||
rerender(<Checkbox {...props} name="another-name" />);
|
||||
|
||||
expect(screen.getByRole('checkbox')).toHaveAttribute('name', 'another-name');
|
||||
});
|
||||
|
||||
test('корректно отрабатывает клик', async () => {
|
||||
const { findByRole, rerender } = render(<Checkbox {...props} />);
|
||||
|
||||
expect(onChangeMock).not.toHaveBeenCalled();
|
||||
|
||||
userEvent.click(screen.getByRole('checkbox'));
|
||||
expect(onChangeMock).toHaveBeenCalledTimes(1);
|
||||
expect(onChangeMock).toHaveBeenCalledWith(true, expect.any(Object));
|
||||
|
||||
rerender(<Checkbox {...props} value />);
|
||||
|
||||
const checkbox = await findByRole('checkbox');
|
||||
|
||||
userEvent.click(checkbox);
|
||||
expect(onChangeMock).toHaveBeenCalledTimes(2);
|
||||
expect(onChangeMock).toHaveBeenCalledWith(false, expect.any(Object));
|
||||
});
|
||||
|
||||
test('корректно отрабатывает при disabled', () => {
|
||||
render(<Checkbox {...props} disabled />);
|
||||
|
||||
expect(onChangeMock).not.toHaveBeenCalled();
|
||||
|
||||
userEvent.click(screen.getByRole('checkbox'));
|
||||
expect(onChangeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('отображается кастомная иконка в лейбле', async () => {
|
||||
const { container } = render(<Checkbox {...props} label={label} tooltip="tooltip" tooltipIcon={InfoIcon} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const icon = container.querySelector('[data-name=Info]');
|
||||
|
||||
icon && userEvent.click(icon);
|
||||
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('не отрабатывает onChange чекбокса при клике на дефолтную иконку тултипа', async () => {
|
||||
const { container } = render(<Checkbox {...props} label={label} tooltip="tooltip" />);
|
||||
|
||||
await waitFor(() => {
|
||||
const icon = container.querySelector('[data-name=Question]');
|
||||
|
||||
icon && userEvent.click(icon);
|
||||
});
|
||||
|
||||
expect(onChangeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import type { IconComponent, IconName } from '@fractal-ui/library';
|
||||
import { QuestionIcon } from '@fractal-ui/library';
|
||||
|
||||
/** Иконка для отображения подсказки с предотвращением выполнения действия по умолчанию при нажатии. */
|
||||
export const IconWithPreventDefault: IconComponent<IconName> = props => <QuestionIcon {...props} onClick={e => e.preventDefault()} />;
|
||||
|
||||
IconWithPreventDefault.displayName = 'IconWithPreventDefault';
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { FC, ChangeEvent } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { ROLE } from '@msb/fractal-ui-core';
|
||||
import { MinusIcon, TickIcon } from '@fractal-ui/library';
|
||||
import { Text, Wrapper } from '@msb/fractal-ui-styling';
|
||||
import Label from '../inputs-label';
|
||||
import { IconWithPreventDefault } from './icon-with-prevent-default';
|
||||
import { StyledInput, StyledLabel, StyledCheckbox, checkboxStyleProps } from './styled';
|
||||
import type { CheckboxProps } from './types';
|
||||
|
||||
/**
|
||||
* Компонент Checkbox.
|
||||
*
|
||||
* @see https://www.figma.com/design/CizcXMEqxBSENC0hXJATi3/Fractal-UI-Kit?node-id=113-5308&t=SNjk6yRjcZpnjO02-0
|
||||
*/
|
||||
const Checkbox: FC<CheckboxProps> = ({
|
||||
disabled = false,
|
||||
isIndeterminate = false,
|
||||
name,
|
||||
hasError = false,
|
||||
errorText,
|
||||
value = false,
|
||||
label,
|
||||
size = 'M',
|
||||
tooltip,
|
||||
tooltipPosition = 'bottom center',
|
||||
tabIndex,
|
||||
onChange,
|
||||
className,
|
||||
tooltipIcon: TooltipIcon,
|
||||
color = 'text.primary',
|
||||
...props
|
||||
}) => {
|
||||
const sizeProp = `checkbox.${size}`;
|
||||
const sizeIcon = `checkbox.icon.${size}`;
|
||||
const ErrorText = size === 'M' ? Text.P2 : Text.P3;
|
||||
|
||||
/** Обработчик события изменения состояния чекбокса. */
|
||||
const onChangeHandler = useCallback((e: ChangeEvent<HTMLInputElement>) => onChange?.(e.target.checked, e), [onChange]);
|
||||
|
||||
return (
|
||||
<Wrapper className={className} display="inline-flex" flexDirection="column">
|
||||
<StyledLabel disabled={disabled}>
|
||||
<StyledCheckbox
|
||||
{...checkboxStyleProps}
|
||||
borderRadius={sizeProp}
|
||||
checked={value}
|
||||
disabled={disabled}
|
||||
hasError={hasError}
|
||||
height={sizeProp}
|
||||
isIndeterminate={isIndeterminate}
|
||||
mr={label ? 'checkbox.text' : undefined}
|
||||
width={sizeProp}
|
||||
>
|
||||
<StyledInput
|
||||
aria-checked={value}
|
||||
checked={value}
|
||||
data-checked={value}
|
||||
data-indeterminate={isIndeterminate}
|
||||
data-name={name}
|
||||
data-role={ROLE.CHECKBOX}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
tabIndex={tabIndex || !disabled ? 1 : void 0}
|
||||
type="checkbox"
|
||||
onChange={onChangeHandler}
|
||||
/>
|
||||
<Wrapper
|
||||
alignItems="flex"
|
||||
color={disabled ? 'control.disabled.typo' : 'control.primary.typo'}
|
||||
display="flex"
|
||||
height={sizeIcon}
|
||||
overflow="hidden"
|
||||
width={sizeIcon}
|
||||
>
|
||||
{!isIndeterminate && value ? <TickIcon size={size} /> : null}
|
||||
{isIndeterminate ? <MinusIcon size={size} /> : null}
|
||||
</Wrapper>
|
||||
</StyledCheckbox>
|
||||
{label && (
|
||||
<Wrapper mt={`checkbox.label.marginTop.${size}`}>
|
||||
<Label
|
||||
short
|
||||
{...props}
|
||||
color={color}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
size={size}
|
||||
tooltip={tooltip}
|
||||
tooltipIcon={TooltipIcon ?? IconWithPreventDefault}
|
||||
tooltipPosition={tooltipPosition}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
</Wrapper>
|
||||
)}
|
||||
</StyledLabel>
|
||||
{hasError && errorText && (
|
||||
<Wrapper color="text.error" marginLeft={`checkbox.error.marginLeft.${size}`} marginTop={`checkbox.error.marginTop.${size}`}>
|
||||
<ErrorText data-name={name} data-role={ROLE.ERROR}>
|
||||
{errorText}
|
||||
</ErrorText>
|
||||
</Wrapper>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
|
||||
export default Checkbox;
|
||||
@@ -0,0 +1,94 @@
|
||||
import styled from '@emotion/styled';
|
||||
import type { IsIndeterminateProps, DisabledProps, CheckedProps, HasErrorProps, WrapperProps } from '@msb/fractal-ui-styling';
|
||||
import { Wrapper } from '@msb/fractal-ui-styling';
|
||||
import styledCss from '@styled-system/css';
|
||||
import { typography } from 'styled-system';
|
||||
import type { TypographyProps } from 'styled-system';
|
||||
|
||||
const errorColor = 'text.error';
|
||||
|
||||
export const checkboxStyleProps: WrapperProps = {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'control.bg',
|
||||
borderColor: 'control.border',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: 'checkbox',
|
||||
boxSizing: 'border-box',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
transition: 'border-color 0.3s, background-color 0.3s',
|
||||
position: 'relative',
|
||||
};
|
||||
|
||||
/** Компонент нативного чекбокса. */
|
||||
export const StyledInput = styled.input({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
opacity: '0',
|
||||
position: 'absolute',
|
||||
margin: 0,
|
||||
zIndex: -1,
|
||||
});
|
||||
|
||||
/** Компонент лейбла. */
|
||||
export const StyledLabel = styled.label<TypographyProps & { disabled: boolean }>(
|
||||
{
|
||||
display: 'inline-flex',
|
||||
transition: 'color 0.3s',
|
||||
willChange: 'color',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
({ disabled }) =>
|
||||
styledCss({
|
||||
color: disabled ? 'control.disabled.typo' : 'text.primary',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
}),
|
||||
typography
|
||||
);
|
||||
|
||||
/** Компонент стилизации чекбокса. */
|
||||
export const StyledCheckbox = styled(Wrapper)<Required<CheckedProps & DisabledProps & HasErrorProps & IsIndeterminateProps>>(
|
||||
{
|
||||
willChange: 'border-color, background-color',
|
||||
},
|
||||
({ disabled }) =>
|
||||
styledCss(
|
||||
disabled
|
||||
? {
|
||||
cursor: 'not-allowed',
|
||||
backgroundColor: 'control.disabled.element',
|
||||
borderColor: 'transparent',
|
||||
}
|
||||
: {
|
||||
'&:hover': {
|
||||
borderColor: 'control.borderHover',
|
||||
},
|
||||
}
|
||||
),
|
||||
|
||||
({ checked, isIndeterminate, disabled }) =>
|
||||
(checked || isIndeterminate) &&
|
||||
!disabled &&
|
||||
styledCss({
|
||||
backgroundColor: 'control.primary.bg',
|
||||
borderColor: 'transparent',
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: 'control.primary.bgHover',
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
}),
|
||||
|
||||
({ hasError, disabled }) =>
|
||||
hasError &&
|
||||
!disabled &&
|
||||
styledCss({
|
||||
backgroundColor: 'control.errorBg',
|
||||
borderColor: errorColor,
|
||||
|
||||
'&:hover': {
|
||||
borderColor: errorColor,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ReactNode, SyntheticEvent } from 'react';
|
||||
import type { Theme } from '@emotion/react';
|
||||
import type { SizeM, HasErrorProps, ErrorTextProps, IsIndeterminateProps, SizeS, StyledComponentProps } from '@msb/fractal-ui-styling';
|
||||
import type { LabelProps } from '../inputs-label/types';
|
||||
|
||||
/** Размеры чекбокса. */
|
||||
export type CheckboxSize = SizeM | SizeS;
|
||||
|
||||
/** Интерфейс пропсов чекбокса. */
|
||||
export interface BaseCheckboxProps extends Omit<LabelProps, 'size' | 'width'>, HasErrorProps, ErrorTextProps, IsIndeterminateProps {
|
||||
/**
|
||||
* Состояние активности чекбокса.
|
||||
*/
|
||||
value?: boolean;
|
||||
/**
|
||||
* Текст/ссылка рядом с чекбоксом.
|
||||
*/
|
||||
label?: ReactNode;
|
||||
/**
|
||||
* Порядковый номер при навигации клавиатурой.
|
||||
*/
|
||||
tabIndex?: number;
|
||||
/**
|
||||
* Размеры чекбокса.
|
||||
*/
|
||||
size?: CheckboxSize;
|
||||
/**
|
||||
* Колбэк при клике на чекбокс.
|
||||
*/
|
||||
onChange?(value: boolean, e: SyntheticEvent<HTMLInputElement>): void;
|
||||
}
|
||||
|
||||
/** Свойства чекбокса. */
|
||||
export type CheckboxProps = StyledComponentProps<
|
||||
React.ComponentType<Omit<React.AllHTMLAttributes<HTMLInputElement>, 'label' | 'onChange' | 'size' | 'value'>>,
|
||||
Theme,
|
||||
BaseCheckboxProps,
|
||||
never
|
||||
>;
|
||||
@@ -0,0 +1,265 @@
|
||||
import React from 'react';
|
||||
import { Title, Controls, Description, Source, Heading, Canvas, Subheading } from '@storybook/blocks';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { StoryCode } from 'common';
|
||||
import ChipsGroup from '..';
|
||||
import { options } from './constants';
|
||||
import {
|
||||
ActiveStory,
|
||||
AdaptiveStory,
|
||||
SandboxStory,
|
||||
DisabledStory,
|
||||
ErrorStory,
|
||||
InvertedStory,
|
||||
SingleLineStory,
|
||||
SizeStory,
|
||||
StructureStory,
|
||||
WithNameStory,
|
||||
} from './examples';
|
||||
|
||||
export {
|
||||
ActiveStory,
|
||||
AdaptiveStory,
|
||||
SandboxStory,
|
||||
DisabledStory,
|
||||
ErrorStory,
|
||||
InvertedStory,
|
||||
SingleLineStory,
|
||||
SizeStory,
|
||||
StructureStory,
|
||||
WithNameStory,
|
||||
};
|
||||
SandboxStory.storyName = 'Песочница';
|
||||
SandboxStory.args = {
|
||||
disabled: false,
|
||||
errorText: '',
|
||||
inverted: false,
|
||||
readOnly: false,
|
||||
label: 'Наименование',
|
||||
labelPosition: 'top',
|
||||
labelWidth: 300,
|
||||
name: 'test',
|
||||
options,
|
||||
singleLine: false,
|
||||
size: 'M',
|
||||
tooltip: '',
|
||||
};
|
||||
StructureStory.storyName = 'Структура';
|
||||
WithNameStory.storyName = 'С названием';
|
||||
SizeStory.storyName = 'Размер';
|
||||
SingleLineStory.storyName = 'Элементы группы одной строкой';
|
||||
ActiveStory.storyName = 'Состояние / Активная ячейка';
|
||||
DisabledStory.storyName = 'Состояние / Недоступное состояние';
|
||||
ErrorStory.storyName = 'Состояние / Сообщение с ошибкой';
|
||||
InvertedStory.storyName = 'Состояние / Инвертированное состояние';
|
||||
AdaptiveStory.storyName = 'Пример адаптивной вёрстки';
|
||||
|
||||
const Docs: React.FC = () => (
|
||||
<>
|
||||
<Title>ChipsGroup</Title>
|
||||
<Description of={ChipsGroup} />
|
||||
<Source
|
||||
code={`
|
||||
/** Импорт компонента ChipsGroup. */
|
||||
import { ChipsGroup } from '@msb/fractal-ui-composites';
|
||||
|
||||
<ChipsGroup name="fileFormat" options={options} value={value} onChange={onChange} />
|
||||
`}
|
||||
language="tsx"
|
||||
/>
|
||||
|
||||
<Heading>Структура</Heading>
|
||||
<p>
|
||||
Элементами опций ChipsGroup являются компоненты <a href="/?path=/docs/отображение-данных-chips--chips">Chips</a>.
|
||||
</p>
|
||||
<Canvas of={StructureStory} />
|
||||
|
||||
<Heading>С названием</Heading>
|
||||
<p>
|
||||
Задается свойством <StoryCode>label</StoryCode>, позиционирование относительно поля задается свойством{' '}
|
||||
<StoryCode>labelPosition</StoryCode>, которое может принимать 2 значения <StoryCode>"left" | "top"</StoryCode> (по умолчанию{' '}
|
||||
<StoryCode>"top"</StoryCode>
|
||||
).
|
||||
</p>
|
||||
<Canvas of={WithNameStory} />
|
||||
|
||||
<Heading>Внешний вид</Heading>
|
||||
<p>Доступны следующие варианты настроек внешнего вида компонента:</p>
|
||||
<ul>
|
||||
<li>
|
||||
размер <StoryCode>size</StoryCode>;
|
||||
</li>
|
||||
<li>
|
||||
элементы группы одной строкой <StoryCode>singleLine</StoryCode>.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Heading>Размер</Heading>
|
||||
<p>
|
||||
Размер указывается пропсом <StoryCode>size</StoryCode>.
|
||||
</p>
|
||||
<p>
|
||||
По умолчанию <StoryCode>M</StoryCode>.
|
||||
</p>
|
||||
<Canvas of={SizeStory} />
|
||||
|
||||
<Heading>Элементы группы одной строкой</Heading>
|
||||
<p>
|
||||
Расположение элементов группы одной строкой указывается пропсом <StoryCode>singleLine</StoryCode>.
|
||||
</p>
|
||||
<p>
|
||||
По умолчанию <StoryCode>singleLine</StoryCode> равен <StoryCode>false</StoryCode>.
|
||||
</p>
|
||||
<Canvas of={SingleLineStory} />
|
||||
|
||||
<Heading>Адаптивность</Heading>
|
||||
<p>Если ширина группы чипсов менее 288px, то на мобильных устройствах она растягивается по ширине формы.</p>
|
||||
|
||||
<Heading>Активная ячейка</Heading>
|
||||
<p>
|
||||
Элемент группы является активным, когда проп <StoryCode>value</StoryCode> компонента <StoryCode>ChipsGroup</StoryCode> равен значению{' '}
|
||||
<StoryCode>value</StoryCode> этой ячейки в массиве <StoryCode>options</StoryCode>.
|
||||
</p>
|
||||
<p>
|
||||
Если проп <StoryCode>value</StoryCode> является массивом, то возможен мультивыбор.
|
||||
</p>
|
||||
<p>
|
||||
Если в проп <StoryCode>value</StoryCode> не передан или передана строка - это состояние выбора одного значения.
|
||||
</p>
|
||||
<p>
|
||||
Если в проп <StoryCode>value</StoryCode> передан пустой массив или со значениями из пропа <StoryCode>options</StoryCode> - это
|
||||
состояние мультивыбора.
|
||||
</p>
|
||||
<Canvas of={ActiveStory} />
|
||||
|
||||
<Heading>onChange</Heading>
|
||||
<p>
|
||||
Функция <StoryCode>onChange</StoryCode> срабатывает, когда происходит смена активной ячейки. При мультивыборе, если ячейка была
|
||||
неактивной - становится активной, и наоборот.
|
||||
</p>
|
||||
<p>Обработать переключение ячеек можно, например, следующим образом для единичного выбора:</p>
|
||||
<Source
|
||||
code={`
|
||||
const [format, setFormat] = useState('PDF');
|
||||
|
||||
const onChange = value => setFormat(value);
|
||||
|
||||
<ChipsGroup name="format" options={fileFormatOptions} value={format} onChange={onChange} />
|
||||
`}
|
||||
language="tsx"
|
||||
/>
|
||||
<p>Пример для множественного выбора:</p>
|
||||
<Source
|
||||
code={`
|
||||
const [formatList, setFormatList] = useState(['PDF', 'TXT']);
|
||||
|
||||
const onChange = value => setFormat(value);
|
||||
|
||||
<ChipsGroup name="formats" options={fileFormatOptions} value={formatList} onChange={onChange} />
|
||||
`}
|
||||
language="tsx"
|
||||
/>
|
||||
|
||||
<Heading>Недоступное состояние</Heading>
|
||||
<p>
|
||||
Проп <StoryCode>disabled</StoryCode> управляет состоянием компонента:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<StoryCode>false</StoryCode> - компонент доступен;
|
||||
</li>
|
||||
<li>
|
||||
<StoryCode>true</StoryCode> - компонент недоступен.
|
||||
</li>
|
||||
</ul>
|
||||
<Canvas of={DisabledStory} />
|
||||
|
||||
<Heading>Сообщение с ошибкой</Heading>
|
||||
<p>
|
||||
Проп <StoryCode>errorText</StoryCode> отображает сообщение с ошибкой под группой элементов.
|
||||
</p>
|
||||
<Canvas of={ErrorStory} />
|
||||
|
||||
<Heading>Инвертированное состояние</Heading>
|
||||
<p>
|
||||
Проп <StoryCode>inverted</StoryCode> задает инвертированное состояние компонента.
|
||||
</p>
|
||||
<Canvas of={InvertedStory} />
|
||||
|
||||
<Heading>Пример адаптивной вёрстки</Heading>
|
||||
<p>
|
||||
Для опций можно задать <StoryCode>flex</StoryCode> и <StoryCode>width</StoryCode> css-свойства, а также менять вёрстку компонента
|
||||
относительно лейбла через <StoryCode>gridTemplateColumns</StoryCode>.
|
||||
<Source
|
||||
code={`
|
||||
<ChipsGroup
|
||||
{...}
|
||||
gridTemplateColumns='minmax(0,1fr) auto'
|
||||
label="Адаптивный ChipsGroup"
|
||||
options={[
|
||||
{
|
||||
label: 'Chips 1',
|
||||
value: '1',
|
||||
flexGrow: 1,
|
||||
},
|
||||
{
|
||||
label: 'Chips 2',
|
||||
value: '2',
|
||||
width: '50%',
|
||||
}
|
||||
]}
|
||||
/>
|
||||
`}
|
||||
/>
|
||||
</p>
|
||||
<Canvas of={AdaptiveStory} />
|
||||
|
||||
<Heading>Автотесты</Heading>
|
||||
<p>
|
||||
Компонент <StoryCode>ChipsGroup</StoryCode> имеет следующие атрибуты, описанные ниже.
|
||||
</p>
|
||||
<p>Все атрибуты всегда присутствуют в html-разметке. Значение атрибутов соответствуют значению пропсов.</p>
|
||||
|
||||
<Subheading>[data-disabled]</Subheading>
|
||||
<p>
|
||||
Значение атрибута заполняется из свойства <StoryCode>disabled</StoryCode>.
|
||||
</p>
|
||||
<Subheading>[data-name]</Subheading>
|
||||
<p>
|
||||
Значение атрибута заполняется из свойства <StoryCode>name</StoryCode> компонента.
|
||||
</p>
|
||||
<Subheading>[data-role]</Subheading>
|
||||
<p>
|
||||
Все чипсы имеют значение <StoryCode>data-role=chips</StoryCode>.
|
||||
</p>
|
||||
<p>
|
||||
Все наименования имеют значение <StoryCode>data-role=title</StoryCode>.
|
||||
</p>
|
||||
<p>
|
||||
Все подсказки имеют значение <StoryCode>data-role=description</StoryCode>.
|
||||
</p>
|
||||
|
||||
<Heading>Песочница</Heading>
|
||||
<Canvas of={SandboxStory} />
|
||||
|
||||
<Heading>API</Heading>
|
||||
<Controls of={SandboxStory} />
|
||||
</>
|
||||
);
|
||||
|
||||
const StoryMeta: Meta = {
|
||||
title: 'Отображение данных/ChipsGroup',
|
||||
component: ChipsGroup,
|
||||
parameters: {
|
||||
docs: {
|
||||
page: Docs,
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
width: {
|
||||
description: 'Для возможности изменения ширины пользовательского контейнера',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default StoryMeta;
|
||||
@@ -0,0 +1,117 @@
|
||||
import { CalendarIcon, HamburgerMenuIcon, QrCodeIcon } from '@fractal-ui/library';
|
||||
import type { ChipsOption } from '../types';
|
||||
|
||||
export const options: ChipsOption[] = [
|
||||
{
|
||||
label: 'PDF',
|
||||
value: 'PDF',
|
||||
},
|
||||
{
|
||||
label: '1C',
|
||||
value: '1C',
|
||||
},
|
||||
{
|
||||
label: 'TXT',
|
||||
value: 'TXT',
|
||||
},
|
||||
{
|
||||
label: 'EXCEL',
|
||||
value: 'EXCEL',
|
||||
},
|
||||
{
|
||||
label: 'MT 940',
|
||||
value: 'MT940',
|
||||
},
|
||||
{
|
||||
label: 'SAP',
|
||||
value: 'SAP',
|
||||
},
|
||||
{
|
||||
label: 'XPS',
|
||||
value: 'XPS',
|
||||
},
|
||||
{
|
||||
label: 'RTF',
|
||||
value: 'RTF',
|
||||
},
|
||||
];
|
||||
|
||||
export const fileFormatOptions: ChipsOption[] = [
|
||||
{
|
||||
label: 'PDF',
|
||||
value: 'PDF',
|
||||
},
|
||||
{
|
||||
label: '1C',
|
||||
value: '1C',
|
||||
},
|
||||
{
|
||||
label: 'TXT',
|
||||
value: 'TXT',
|
||||
},
|
||||
{
|
||||
label: 'EXCEL',
|
||||
value: 'EXCEL',
|
||||
},
|
||||
{
|
||||
label: 'MT 940',
|
||||
value: 'MT940',
|
||||
},
|
||||
{
|
||||
label: 'SAP',
|
||||
value: 'SAP',
|
||||
},
|
||||
{
|
||||
label: 'XPS',
|
||||
value: 'XPS',
|
||||
},
|
||||
{
|
||||
label: 'RTF',
|
||||
value: 'RTF',
|
||||
},
|
||||
];
|
||||
|
||||
export const textOptions: ChipsOption[] = [
|
||||
{ label: '0%', value: '0' },
|
||||
{ label: '10%', value: '10' },
|
||||
{ label: '20%', value: '20' },
|
||||
];
|
||||
|
||||
export const iconTextOptions: ChipsOption[] = [
|
||||
{ icon: HamburgerMenuIcon, label: 'Список', value: 'list' },
|
||||
{ icon: QrCodeIcon, label: 'Плитка', value: 'tile' },
|
||||
{ icon: CalendarIcon, label: 'Календарь', value: 'calendar' },
|
||||
];
|
||||
|
||||
export const iconTextBadgeOptions: ChipsOption[] = [
|
||||
{ badge: '10', icon: HamburgerMenuIcon, label: 'Список', value: 'list' },
|
||||
{ badge: '12', icon: QrCodeIcon, label: 'Плитка', value: 'tile' },
|
||||
{ badge: '100', icon: CalendarIcon, label: 'Календарь', value: 'calendar' },
|
||||
];
|
||||
|
||||
export const textBadgeOptions: ChipsOption[] = [
|
||||
{ badge: '10', label: 'Список', value: 'list' },
|
||||
{ badge: '12', label: 'Плитка', value: 'tile' },
|
||||
{ badge: '100', label: 'Календарь', value: 'calendar' },
|
||||
];
|
||||
|
||||
export const textOptionsWithDisabledItem: ChipsOption[] = [
|
||||
{ label: 'Disabled 1', value: '1', disabled: true },
|
||||
{ label: 'Enabled 1', value: '2' },
|
||||
{ label: 'Disabled 2', value: '3', disabled: true },
|
||||
{ label: 'Enabled 2', value: '4' },
|
||||
{ label: 'Enabled 3', value: '5' },
|
||||
];
|
||||
|
||||
export const binaryOptions = [
|
||||
{
|
||||
label: 'Да',
|
||||
value: 'Yes',
|
||||
flexGrow: 1,
|
||||
},
|
||||
{
|
||||
label: 'Нет',
|
||||
value: 'No',
|
||||
flexGrow: 1,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import type { FieldComponents } from '@msb/fractal-ui-form';
|
||||
import { Fields } from '@msb/fractal-ui-form';
|
||||
import { useBreakpoints, Wrapper } from '@msb/fractal-ui-styling';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { useArgs } from '@storybook/preview-api';
|
||||
import type { StoryFn } from '@storybook/react';
|
||||
import { loremIpsumParagraph, SandboxField, SandboxStoryForm } from 'common';
|
||||
import ChipsGroup from '..';
|
||||
import type { ChipsGroupValue, ChipsGroupProps } from '../types';
|
||||
import {
|
||||
textOptions,
|
||||
iconTextOptions,
|
||||
textBadgeOptions,
|
||||
fileFormatOptions,
|
||||
iconTextBadgeOptions,
|
||||
textOptionsWithDisabledItem,
|
||||
binaryOptions,
|
||||
} from './constants';
|
||||
|
||||
export const SandboxStory: StoryFn<ChipsGroupProps> = args => {
|
||||
const [_, updateArgs] = useArgs();
|
||||
|
||||
const { inverted } = args;
|
||||
|
||||
const onChange: ChipsGroupProps['onChange'] = value => {
|
||||
action('onChange')(value);
|
||||
updateArgs({ ...args, value });
|
||||
};
|
||||
|
||||
const onChangeField: ComponentProps<FieldComponents['ChipsGroup']>['onChange'] = ({ value }) => onChange(value);
|
||||
|
||||
return (
|
||||
<Wrapper backgroundColor={inverted ? 'bg.secondary' : 'transparent'} p="3">
|
||||
<SandboxStoryForm
|
||||
fieldComponent={
|
||||
<SandboxField<'ChipsGroup'>
|
||||
Field={Fields.ChipsGroup}
|
||||
{...(args as ComponentProps<FieldComponents['ChipsGroup']>)}
|
||||
onChange={onChangeField}
|
||||
/>
|
||||
}
|
||||
fieldType="ChipsGroup"
|
||||
mainComponent={<ChipsGroup {...args} onChange={onChange} />}
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const StructureStory: StoryFn<ChipsGroupProps> = () => {
|
||||
const [text, setText] = useState<ChipsGroupValue>(textOptions[0].value);
|
||||
const [iconText, setIconText] = useState<ChipsGroupValue>(iconTextOptions[0].value);
|
||||
const [iconTextBadge, setIconTextBadge] = useState<ChipsGroupValue>(iconTextBadgeOptions[0].value);
|
||||
const [textBadge, setTextBadge] = useState<ChipsGroupValue>(textBadgeOptions[0].value);
|
||||
const [defaultNullValue, setDefaultNullValue] = useState<ChipsGroupValue | null>(null);
|
||||
const [disabledGroupValue, setDisabledGroupValue] = useState<ChipsGroupValue>(textOptionsWithDisabledItem[1].value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h4>Группа содержит только текст</h4>
|
||||
<ChipsGroup name="text" options={textOptions} value={text} onChange={v => setText(v ?? '')} />
|
||||
<br />
|
||||
<h4>Группа содержит иконки и текст</h4>
|
||||
<ChipsGroup name="iconText" options={iconTextOptions} value={iconText} onChange={v => setIconText(v ?? '')} />
|
||||
<br />
|
||||
<h4>Группа содержит иконки, текст и бейдж</h4>
|
||||
<ChipsGroup name="iconTextBadge" options={iconTextBadgeOptions} value={iconTextBadge} onChange={v => setIconTextBadge(v ?? '')} />
|
||||
<br />
|
||||
<h4>Группа содержит текст и бейдж</h4>
|
||||
<ChipsGroup name="textBadge" options={textBadgeOptions} value={textBadge} onChange={v => setTextBadge(v ?? '')} />
|
||||
<h4>Группа, у которой по умолчанию не выбран ни один элемент</h4>
|
||||
<ChipsGroup
|
||||
name="defaultNullValue"
|
||||
options={textBadgeOptions}
|
||||
value={defaultNullValue as ChipsGroupValue}
|
||||
onChange={v => setDefaultNullValue(v ?? '')}
|
||||
/>
|
||||
<h4>Группа содержит недоступные элементы</h4>
|
||||
<ChipsGroup
|
||||
name="disabledGroupValue"
|
||||
options={textOptionsWithDisabledItem}
|
||||
value={disabledGroupValue}
|
||||
onChange={v => setDisabledGroupValue(v ?? '')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithNameStory: StoryFn<ChipsGroupProps> = () => {
|
||||
const [value, setValue] = useState<ChipsGroupValue>(textOptions[0].value);
|
||||
const [value2, setValue2] = useState<ChipsGroupValue>(textOptions[0].value);
|
||||
const [value3, setValue3] = useState<ChipsGroupValue>(textOptions[0].value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
Задано свойство{' '}
|
||||
<code>
|
||||
<b>label</b>
|
||||
</code>
|
||||
</p>
|
||||
<ChipsGroup label="Наименование" name="text" options={textOptions} value={value} onChange={v => setValue(v ?? '')} />
|
||||
<p>
|
||||
Задано свойство{' '}
|
||||
<code>
|
||||
<b>label</b>
|
||||
</code>{' '}
|
||||
и{' '}
|
||||
<code>
|
||||
<b>labelPosition="left"</b>
|
||||
</code>
|
||||
.{' '}
|
||||
</p>
|
||||
<ChipsGroup
|
||||
label="Наименование"
|
||||
labelPosition="left"
|
||||
name="text"
|
||||
options={textOptions}
|
||||
value={value2}
|
||||
onChange={v => setValue2(v ?? '')}
|
||||
/>
|
||||
<h4>C всплывающим тултипом</h4>
|
||||
<p>
|
||||
Тултип позиционируется просом{' '}
|
||||
<code>
|
||||
<b>tooltipPosition</b>
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
<ChipsGroup
|
||||
label="Наименование"
|
||||
labelPosition="left"
|
||||
name="text"
|
||||
options={textOptions}
|
||||
tooltip="Подсказка"
|
||||
value={value3}
|
||||
onChange={v => setValue3(v ?? '')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const SizeStory: StoryFn<ChipsGroupProps> = () => {
|
||||
const [value, setValue] = useState<ChipsGroupValue>(textOptions[0].value);
|
||||
const [value2, setValue2] = useState<ChipsGroupValue>(textOptions[0].value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>Размер M</h3>
|
||||
<ChipsGroup name="text" options={textOptions} value={value} onChange={v => setValue(v ?? '')} />
|
||||
<br />
|
||||
<h3>Размер S</h3>
|
||||
<ChipsGroup name="text" options={textOptions} size="S" value={value2} onChange={v => setValue2(v ?? '')} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const SingleLineStory: StoryFn<ChipsGroupProps> = () => {
|
||||
const [value, setValue] = useState<ChipsGroupValue>(fileFormatOptions[0].value);
|
||||
const [value2, setValue2] = useState<ChipsGroupValue>(fileFormatOptions[0].value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>Одной строкой</h3>
|
||||
<Wrapper backgroundColor="bg.secondary" p="3">
|
||||
<ChipsGroup inverted singleLine name="fileFormat" options={fileFormatOptions} value={value} onChange={v => setValue(v ?? '')} />
|
||||
</Wrapper>
|
||||
<br />
|
||||
<h3>С переносом</h3>
|
||||
<Wrapper backgroundColor="bg.secondary" p="3">
|
||||
<ChipsGroup inverted name="fileFormat" options={fileFormatOptions} value={value2} onChange={v => setValue2(v ?? '')} />
|
||||
</Wrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ActiveStory: StoryFn<ChipsGroupProps> = () => {
|
||||
const [percentValue, setPercentValue] = useState<ChipsGroupValue>(textOptions[0].value);
|
||||
const [formatsValue, setFormatsValue] = useState<ChipsGroupValue>([fileFormatOptions[0].value, fileFormatOptions[2].value]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h4>Единичный выбор (одна активная ячейка)</h4>
|
||||
<ChipsGroup singleLine name="percent" options={textOptions} value={percentValue} onChange={v => setPercentValue(v ?? '')} />
|
||||
<br />
|
||||
<h4>Множественный выбор</h4>
|
||||
<ChipsGroup singleLine name="format" options={fileFormatOptions} value={formatsValue} onChange={v => setFormatsValue(v ?? [])} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const DisabledStory: StoryFn<ChipsGroupProps> = () => {
|
||||
const [value, setValue] = useState<ChipsGroupValue>(fileFormatOptions[0].value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h4>Недоступное состояние компонента</h4>
|
||||
<ChipsGroup disabled name="format" options={fileFormatOptions} value={value} onChange={v => setValue(v ?? '')} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ErrorStory: StoryFn<ChipsGroupProps> = () => {
|
||||
const [value, setValue] = useState<ChipsGroupValue>(fileFormatOptions[0].value);
|
||||
|
||||
return <ChipsGroup errorText="Ошибка" name="fileFormat" options={fileFormatOptions} value={value} onChange={v => setValue(v ?? '')} />;
|
||||
};
|
||||
|
||||
export const InvertedStory: StoryFn<ChipsGroupProps> = () => {
|
||||
const [value, setValue] = useState<ChipsGroupValue>(fileFormatOptions[0].value);
|
||||
|
||||
return (
|
||||
<Wrapper backgroundColor="bg.secondary" p="3">
|
||||
<ChipsGroup inverted name="fileFormat" options={fileFormatOptions} value={value} onChange={v => setValue(v ?? '')} />
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const ChipsGroupAdaptive: React.FC = () => {
|
||||
const { XS } = useBreakpoints();
|
||||
const [value, setValue] = useState<ChipsGroupValue>(binaryOptions[0].value);
|
||||
const [value2, setValue2] = useState<ChipsGroupValue>(binaryOptions[0].value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h4>Меняйте размер канвы, чтобы увидеть изменения.</h4>
|
||||
<p>Компонент ограничен внешним контейнером шириной в 1000px</p>
|
||||
<Wrapper display="flex" flexDirection="column" gap="3" maxWidth="1000px">
|
||||
<ChipsGroup
|
||||
gridTemplateColumns={{ XS: undefined, S: 'minmax(0,1fr) auto' }}
|
||||
label={loremIpsumParagraph}
|
||||
labelPosition={XS ? 'top' : 'left'}
|
||||
name="binary"
|
||||
options={binaryOptions}
|
||||
singleLine={!!XS}
|
||||
value={value}
|
||||
onChange={v => setValue(v ?? '')}
|
||||
/>
|
||||
<ChipsGroup
|
||||
gridTemplateColumns={{ XS: undefined, S: 'minmax(0,1fr) auto' }}
|
||||
label="Lorem ipsum"
|
||||
labelPosition={XS ? 'top' : 'left'}
|
||||
name="binary"
|
||||
options={binaryOptions}
|
||||
singleLine={!!XS}
|
||||
value={value2}
|
||||
onChange={v => setValue2(v ?? '')}
|
||||
/>
|
||||
</Wrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AdaptiveStory: StoryFn<ChipsGroupProps> = () => <ChipsGroupAdaptive />;
|
||||
@@ -0,0 +1,295 @@
|
||||
import React from 'react';
|
||||
import { type BreakpointValue, useBreakpoints } from '@msb/fractal-ui-styling';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { mockObservers } from 'common';
|
||||
import ChipsGroup from '..';
|
||||
import OptionList from '../option-list';
|
||||
import type { ChipsOption, ChipsGroupValue } from '../types';
|
||||
|
||||
jest.mock('../../../../styling/src/hooks/use-breakpoints');
|
||||
|
||||
const label = 'Наименование';
|
||||
const errorText = 'Ошибка';
|
||||
const tooltip = 'Подсказка';
|
||||
const dataChecked = 'data-checked';
|
||||
|
||||
const options: ChipsOption[] = [
|
||||
{
|
||||
label: 'PDF',
|
||||
value: 'PDF',
|
||||
},
|
||||
{
|
||||
label: '1C',
|
||||
value: '1C',
|
||||
},
|
||||
{
|
||||
label: 'TXT',
|
||||
value: 'TXT',
|
||||
},
|
||||
{
|
||||
label: 'EXCEL',
|
||||
value: 'EXCEL',
|
||||
},
|
||||
{
|
||||
label: 'MT 940',
|
||||
value: 'MT940',
|
||||
},
|
||||
{
|
||||
label: 'SAP',
|
||||
value: 'SAP',
|
||||
},
|
||||
{
|
||||
label: 'XPS',
|
||||
value: 'XPS',
|
||||
},
|
||||
{
|
||||
label: 'RTF',
|
||||
value: 'RTF',
|
||||
},
|
||||
];
|
||||
|
||||
const optionsForStretched: ChipsOption[] = [
|
||||
{
|
||||
label: 'PDF',
|
||||
value: 'PDF',
|
||||
},
|
||||
{
|
||||
label: '1C',
|
||||
value: '1C',
|
||||
},
|
||||
{
|
||||
label: 'TXT',
|
||||
value: 'TXT',
|
||||
},
|
||||
];
|
||||
|
||||
const singleSelectedValue = 'PDF';
|
||||
const multiSelectedValue = ['PDF', 'TXT'];
|
||||
|
||||
describe('ChipsGroup', () => {
|
||||
describe.each<BreakpointValue | undefined>([undefined, 'XS'])('отрисовка компонента', breakpoint => {
|
||||
test.each([singleSelectedValue, multiSelectedValue])('компонент должен корректно отрисоваться', async value => {
|
||||
if (breakpoint) {
|
||||
const mockUseBreakpoints = useBreakpoints as jest.MockedFunction<typeof useBreakpoints> & {
|
||||
mockMatchOne(breakpoint: BreakpointValue): void;
|
||||
};
|
||||
|
||||
mockUseBreakpoints.mockMatchOne(breakpoint);
|
||||
}
|
||||
|
||||
render(<ChipsGroup label={label} name="test" options={options} value={value} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('chips-group')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Проверяет корректность заполнения данных в FieldContainer', () => {
|
||||
describe('Лейбл группы', () => {
|
||||
test('проверяет наличие лейбла у группы', async () => {
|
||||
render(
|
||||
<ChipsGroup label={label} labelPosition="left" labelWidth={200} name="test" options={options} value={singleSelectedValue} />
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(label)).toHaveTextContent(label));
|
||||
});
|
||||
|
||||
test('значок "?" для всплывающей подсказки', async () => {
|
||||
render(<ChipsGroup label={label} name="test" options={options} tooltip={tooltip} value={singleSelectedValue} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('tooltip')).toBeDefined());
|
||||
});
|
||||
|
||||
test('всплывает подсказка при наведении на "?"', async () => {
|
||||
mockObservers();
|
||||
render(<ChipsGroup label={label} name="test" options={options} tooltip={tooltip} value={singleSelectedValue} />);
|
||||
|
||||
const tooltipIcon = screen.getByTestId('tooltip');
|
||||
|
||||
fireEvent.mouseEnter(tooltipIcon);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('popup-container')).toHaveTextContent(tooltip));
|
||||
});
|
||||
|
||||
test('тултип всплывает, когда компонент в состоянии disabled', async () => {
|
||||
mockObservers();
|
||||
render(
|
||||
<ChipsGroup
|
||||
disabled
|
||||
label={label}
|
||||
name="test"
|
||||
options={options}
|
||||
tooltip={tooltip}
|
||||
tooltipPosition="center bottom"
|
||||
value={singleSelectedValue}
|
||||
/>
|
||||
);
|
||||
|
||||
const tooltipIcon = screen.getByTestId('tooltip');
|
||||
|
||||
fireEvent.mouseEnter(tooltipIcon);
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('popup-container')).not.toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
test('проверяет наличие сообщения об ошибке', async () => {
|
||||
render(<ChipsGroup errorText={errorText} label={label} name="test" options={options} value={singleSelectedValue} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(errorText)).toHaveTextContent(errorText));
|
||||
});
|
||||
});
|
||||
|
||||
describe('обработчик выбора одного элемента группы', () => {
|
||||
let checkedValue: ChipsGroupValue;
|
||||
|
||||
const handleChange = (value: ChipsGroupValue) => {
|
||||
checkedValue = value;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
checkedValue = singleSelectedValue;
|
||||
});
|
||||
|
||||
test('проверяет обработчик клика по элементу группы', async () => {
|
||||
const { rerender, getByText } = render(
|
||||
<ChipsGroup label={label} name="test" options={options} value={checkedValue} onChange={handleChange} />
|
||||
);
|
||||
|
||||
const checkedChipsItem = getByText('TXT').parentElement;
|
||||
const uncheckedChipsItem = getByText(singleSelectedValue).parentElement;
|
||||
|
||||
checkedChipsItem && userEvent.click(checkedChipsItem);
|
||||
|
||||
rerender(<ChipsGroup label={label} name="test" options={options} value={checkedValue} onChange={handleChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(checkedChipsItem).toHaveAttribute(dataChecked, 'true');
|
||||
expect(uncheckedChipsItem).toHaveAttribute(dataChecked, 'false');
|
||||
});
|
||||
});
|
||||
|
||||
test('проверяет, что обработчик по элементу группы не срабатывает при disabled', async () => {
|
||||
const { rerender, getByText } = render(
|
||||
<ChipsGroup disabled label={label} name="test" options={options} value={checkedValue} onChange={handleChange} />
|
||||
);
|
||||
|
||||
const clickedChipsItem = getByText('TXT').parentElement;
|
||||
const checkedChipsItem = getByText(singleSelectedValue).parentElement;
|
||||
|
||||
checkedChipsItem && userEvent.click(checkedChipsItem);
|
||||
|
||||
rerender(<ChipsGroup disabled label={label} name="test" options={options} value={checkedValue} onChange={handleChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(clickedChipsItem).toHaveAttribute(dataChecked, 'false');
|
||||
expect(checkedChipsItem).toHaveAttribute(dataChecked, 'true');
|
||||
});
|
||||
});
|
||||
|
||||
test('проверяет, что обработчик по элементу группы не срабатывает при readOnly', async () => {
|
||||
const { rerender, getByText } = render(
|
||||
<ChipsGroup readOnly label={label} name="test" options={options} value={checkedValue} onChange={handleChange} />
|
||||
);
|
||||
|
||||
const clickedChipsItem = getByText('TXT').parentElement;
|
||||
const checkedChipsItem = getByText(singleSelectedValue).parentElement;
|
||||
|
||||
checkedChipsItem && userEvent.click(checkedChipsItem);
|
||||
|
||||
rerender(<ChipsGroup readOnly label={label} name="test" options={options} value={checkedValue} onChange={handleChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(clickedChipsItem).toHaveAttribute(dataChecked, 'false');
|
||||
expect(checkedChipsItem).toHaveAttribute(dataChecked, 'true');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('обработчик множественного выбора', () => {
|
||||
test('проверяет обработчик клика по элементу группы - добавится к массиву', async () => {
|
||||
let checkedValue: ChipsGroupValue = multiSelectedValue;
|
||||
|
||||
const handleChange = (value: ChipsGroupValue) => {
|
||||
checkedValue = value;
|
||||
};
|
||||
|
||||
const { rerender, getByText } = render(
|
||||
<ChipsGroup label={label} name="test" options={options} value={checkedValue} onChange={handleChange} />
|
||||
);
|
||||
|
||||
const firstCheckedChipsItem = getByText(checkedValue[0]).parentElement;
|
||||
const secondCheckedChipsItem = getByText(checkedValue[1]).parentElement;
|
||||
const checkedChipsItem = getByText('1C').parentElement;
|
||||
|
||||
checkedChipsItem && userEvent.click(checkedChipsItem);
|
||||
|
||||
rerender(<ChipsGroup label={label} name="test" options={options} value={checkedValue} onChange={handleChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(firstCheckedChipsItem).toHaveAttribute(dataChecked, 'true');
|
||||
expect(secondCheckedChipsItem).toHaveAttribute(dataChecked, 'true');
|
||||
expect(checkedChipsItem).toHaveAttribute(dataChecked, 'true');
|
||||
});
|
||||
});
|
||||
|
||||
test('проверяет обработчик клика по элементу группы - убирает из массива', async () => {
|
||||
let checkedValue: ChipsGroupValue = multiSelectedValue;
|
||||
|
||||
const handleChange = (value: ChipsGroupValue) => {
|
||||
checkedValue = value;
|
||||
};
|
||||
|
||||
const { rerender, getByText } = render(
|
||||
<ChipsGroup label={label} name="test" options={options} value={checkedValue} onChange={handleChange} />
|
||||
);
|
||||
|
||||
const checkedChipsItem = getByText(checkedValue[0]).parentElement;
|
||||
const uncheckedChipsItem = getByText(checkedValue[1]).parentElement;
|
||||
|
||||
uncheckedChipsItem && userEvent.click(uncheckedChipsItem);
|
||||
|
||||
rerender(<ChipsGroup label={label} name="test" options={options} value={checkedValue} onChange={handleChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(checkedChipsItem).toHaveAttribute(dataChecked, 'true');
|
||||
expect(uncheckedChipsItem).toHaveAttribute(dataChecked, 'false');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('элементы группы располагаются в одну строку', async () => {
|
||||
render(<ChipsGroup singleLine label={label} name="test" options={options} value={singleSelectedValue} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('single-line')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('элементы группы раястягиваются на всю ширину списка', async () => {
|
||||
const mockUseBreakpoints = useBreakpoints as jest.MockedFunction<typeof useBreakpoints> & {
|
||||
setReturnValue(breakpoint: BreakpointValue): void;
|
||||
};
|
||||
|
||||
mockUseBreakpoints.setReturnValue('XS');
|
||||
|
||||
render(<ChipsGroup label={label} name="test" options={optionsForStretched} value={singleSelectedValue} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('chips-group')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('проверяет обработчик клика по элементу группы через клавиатуру', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
const pdfValue: ChipsGroupValue = optionsForStretched[0].value;
|
||||
|
||||
const { getByText } = render(<OptionList options={optionsForStretched} onChange={onChange} />);
|
||||
|
||||
const pdfChipsItem = getByText(pdfValue);
|
||||
|
||||
fireEvent.keyPress(pdfChipsItem, { keyCode: 13 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith(pdfValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Размер группы чипсов, при котором они растягиваются по ширине экрана.
|
||||
*/
|
||||
export const CONTAINER_WIDTH_TO_STRETCH = 288;
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ROLE, ScrollContainer } from '@msb/fractal-ui-core';
|
||||
import { Wrapper } from '@msb/fractal-ui-styling';
|
||||
import type { FieldContainerProps } from '..';
|
||||
import FieldContainer from '../field-container';
|
||||
import InputFooter from '../input/footer';
|
||||
import { MARGIN_FROM_INPUT } from '../input/helpers';
|
||||
import { ContentContainer, StyledTypography } from '../input/styled';
|
||||
import OptionList from './option-list';
|
||||
import type { ChipsGroupProps } from './types';
|
||||
|
||||
/**
|
||||
* Компонент GroupChips.
|
||||
*
|
||||
* Применяется для выбора одного или нескольких значений, в том числе для фильтрации контента. Всегда используется в виде группы из 2–9 чипсов.
|
||||
*
|
||||
* @see https://www.figma.com/file/CizcXMEqxBSENC0hXJATi3/Fractal-UI-Kit?node-id=1206%3A46922
|
||||
*/
|
||||
const ChipsGroup: React.VFC<ChipsGroupProps> = ({
|
||||
disabled,
|
||||
errorText,
|
||||
gridTemplateColumns,
|
||||
label,
|
||||
labelPosition = 'top',
|
||||
labelWidth,
|
||||
name,
|
||||
size = 'M',
|
||||
singleLine,
|
||||
inverted,
|
||||
readOnly,
|
||||
onChange,
|
||||
options,
|
||||
value,
|
||||
...restProps
|
||||
}) => {
|
||||
const withLabel = Boolean(label);
|
||||
|
||||
/** Пропсы для компонента лейбла. */
|
||||
const labelProps: FieldContainerProps['labelProps'] = {
|
||||
...restProps,
|
||||
children: label,
|
||||
disabled,
|
||||
labelPosition,
|
||||
name,
|
||||
size,
|
||||
width: labelWidth,
|
||||
};
|
||||
|
||||
/** Компонент для вспомогательного текста, который выводится под полем ввода (ошибка, подсказка). */
|
||||
const footer = useMemo(
|
||||
() =>
|
||||
Boolean(errorText) && (
|
||||
<ContentContainer width="100%">
|
||||
<StyledTypography
|
||||
data-name={name}
|
||||
data-role={ROLE.DESCRIPTION}
|
||||
lineHeight={`input.${size}`}
|
||||
mb="0px"
|
||||
mt={MARGIN_FROM_INPUT[size]}
|
||||
size={size}
|
||||
>
|
||||
<InputFooter disabled={disabled} errorText={errorText} hasError={Boolean(errorText)} />
|
||||
</StyledTypography>
|
||||
</ContentContainer>
|
||||
),
|
||||
[errorText, name, size, disabled]
|
||||
);
|
||||
|
||||
const optionList = (
|
||||
<OptionList
|
||||
disabled={disabled}
|
||||
inverted={inverted}
|
||||
options={options}
|
||||
readOnly={readOnly}
|
||||
singleLine={singleLine}
|
||||
size={size}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-disabled={disabled} data-name={name} data-role={ROLE.CHIPS} data-testid="chips-group">
|
||||
<FieldContainer gridTemplateColumns={gridTemplateColumns} helperText={footer} labelProps={labelProps} withLabel={withLabel}>
|
||||
{singleLine ? (
|
||||
<Wrapper data-testid="single-line" height={`chips.singleLine${size}`}>
|
||||
<ScrollContainer>{optionList}</ScrollContainer>
|
||||
</Wrapper>
|
||||
) : (
|
||||
optionList
|
||||
)}
|
||||
</FieldContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ChipsGroup.displayName = 'ChipsGroup';
|
||||
|
||||
export default ChipsGroup;
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { useBreakpoints, Wrapper } from '@msb/fractal-ui-styling';
|
||||
import { CONTAINER_WIDTH_TO_STRETCH } from './constants';
|
||||
import { listStyle, listStrechedStyle } from './styled';
|
||||
import type { ChipsListProps } from './types';
|
||||
|
||||
/** Компонент-обертка для списка опций группы. */
|
||||
const OptionListWrapper: React.FC<Pick<ChipsListProps, 'options' | 'singleLine'>> = ({ options, singleLine, children }) => {
|
||||
// Стейт для признака расширения Chips`ов.
|
||||
const [stretched, setStretched] = useState(false);
|
||||
|
||||
/** Ref-объект для получения ширины списка Chips`ов. */
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { XS } = useBreakpoints();
|
||||
|
||||
// Вычисление признака расширения Chips`ов.
|
||||
useEffect(() => {
|
||||
if (ref.current && XS) {
|
||||
setStretched(ref.current.clientWidth < CONTAINER_WIDTH_TO_STRETCH);
|
||||
} else {
|
||||
setStretched(false);
|
||||
}
|
||||
}, [XS, options]);
|
||||
|
||||
if (stretched) {
|
||||
return (
|
||||
<Wrapper {...listStrechedStyle(XS, options.length)} ref={ref}>
|
||||
{children}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper ref={ref} {...listStyle(XS, singleLine)}>
|
||||
{children}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
OptionListWrapper.displayName = 'OptionListWrapper';
|
||||
|
||||
export default OptionListWrapper;
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import Chips from '../chips';
|
||||
import OptionListWrapper from './option-list-wrapper';
|
||||
import type { ChipsListProps, ChipsGroupValue } from './types';
|
||||
|
||||
/** Компонент списка опций группы. */
|
||||
const OptionList = ({ disabled, inverted, readOnly, onChange, options, singleLine, size, value }: ChipsListProps) => {
|
||||
/** Функция вычисления выбранного Chips`а. */
|
||||
const isChecked = useCallback(itemValue => (Array.isArray(value) ? value.includes(itemValue) : itemValue === value), [value]);
|
||||
|
||||
/** Обработчик клика по опции. */
|
||||
const handleClick = useCallback(
|
||||
(itemValue: string) => {
|
||||
if (onChange) {
|
||||
let newValue: ChipsGroupValue;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
newValue = value.includes(itemValue) ? value.filter(item => item !== itemValue) : [...value, itemValue];
|
||||
} else {
|
||||
newValue = itemValue;
|
||||
}
|
||||
|
||||
onChange(newValue);
|
||||
}
|
||||
},
|
||||
[onChange, value]
|
||||
);
|
||||
|
||||
/** Список Chips`ов. */
|
||||
const optionList = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{options.map(({ badge, disabled: disabledItem, icon, label: itemLabel, value: itemValue, ...rest }) => {
|
||||
const checked = isChecked(itemValue);
|
||||
|
||||
return (
|
||||
<Chips
|
||||
key={itemValue}
|
||||
checked={checked}
|
||||
data-checked={checked}
|
||||
data-testid={itemValue}
|
||||
disabled={disabledItem || disabled}
|
||||
flex="none"
|
||||
icon={icon}
|
||||
inverted={inverted}
|
||||
justifyContent="center"
|
||||
readOnly={readOnly}
|
||||
size={size}
|
||||
tabIndex={0}
|
||||
value={badge}
|
||||
onClick={() => (disabledItem || disabled ? undefined : handleClick(itemValue))}
|
||||
onKeyPress={event => (!(disabledItem || disabled) && event.key === 'Enter' ? handleClick(itemValue) : undefined)}
|
||||
{...rest}
|
||||
>
|
||||
{itemLabel}
|
||||
</Chips>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
),
|
||||
[disabled, inverted, readOnly, size, options, handleClick, isChecked]
|
||||
);
|
||||
|
||||
return (
|
||||
<OptionListWrapper options={options} singleLine={singleLine}>
|
||||
{optionList}
|
||||
</OptionListWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
OptionList.displayName = 'OptionList';
|
||||
|
||||
export default OptionList;
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { WrapperProps } from '@msb/fractal-ui-styling';
|
||||
|
||||
/** Базовый стиль для списка Chips`ов. */
|
||||
export const baseListStyle = (XS: boolean): WrapperProps => ({
|
||||
width: '100%',
|
||||
minWidth: XS ? 'chips.optionList.minWidth' : undefined,
|
||||
gap: 'chips.gap',
|
||||
});
|
||||
|
||||
/** Стилизованный контейнер для списка Chips`ов без расширения элементов. */
|
||||
export const listStyle = (XS: boolean, singleLine?: boolean): WrapperProps => ({
|
||||
...baseListStyle(XS),
|
||||
display: 'inline-flex',
|
||||
flexWrap: singleLine ? 'nowrap' : 'wrap',
|
||||
marginRight: !XS && singleLine ? 'chips.mr' : undefined,
|
||||
});
|
||||
|
||||
/** Стилизованный контейнер для списка Chips`ов с расширением элементов. */
|
||||
export const listStrechedStyle = (XS: boolean, count: number): WrapperProps => ({
|
||||
...baseListStyle(XS),
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${count}, 1fr)`,
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ErrorTextProps, InvertedProps, ReadOnlyProps } from '@msb/fractal-ui-styling';
|
||||
import type { BaseChipsProps, FieldContainerProps } from '..';
|
||||
import type { ChipsSize } from '../chips/types';
|
||||
|
||||
/** Свойства лейбла компонента FieldContainer. */
|
||||
type FieldLabelProps = Exclude<FieldContainerProps['labelProps'], undefined>;
|
||||
|
||||
/**
|
||||
* Свойства лейбла компонента.
|
||||
*/
|
||||
interface ChipsGroupLabelProps extends Omit<FieldLabelProps, 'children' | 'size' | 'width'> {
|
||||
/**
|
||||
* Лейбл компонента.
|
||||
*/
|
||||
label?: FieldLabelProps['children'];
|
||||
/**
|
||||
* Ширина лейбла (применяется если labelPosition=left).
|
||||
*/
|
||||
labelWidth?: FieldLabelProps['width'];
|
||||
}
|
||||
|
||||
/** Опция группы. */
|
||||
export interface ChipsOption extends Omit<BaseChipsProps, 'checked' | 'inverted' | 'onClick' | 'readOnly' | 'size' | 'value'> {
|
||||
/**
|
||||
* Значение, которое будет отображаться в компоненте Badge.
|
||||
*/
|
||||
badge?: BaseChipsProps['value'];
|
||||
/**
|
||||
* Значение, которое будет отображаться в опции (Chips).
|
||||
*/
|
||||
label?: BaseChipsProps['children'];
|
||||
/**
|
||||
* Значение опции.
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ChipsGroupSingleLineProps {
|
||||
/**
|
||||
* Группа в одну линию.
|
||||
*/
|
||||
singleLine?: boolean;
|
||||
}
|
||||
|
||||
export interface StretchedProps {
|
||||
/**
|
||||
* Группа растягивается по ширине контейнера.
|
||||
*/
|
||||
stretched?: boolean;
|
||||
}
|
||||
|
||||
export interface ChipsGroupSizeProps {
|
||||
/**
|
||||
* Размер группы Chips.
|
||||
*/
|
||||
size?: ChipsSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Значение поля.
|
||||
*/
|
||||
export type ChipsGroupValue = Array<number | string> | number | string;
|
||||
|
||||
/**
|
||||
* Свойства компонента ChipsGroup.
|
||||
*/
|
||||
export interface ChipsGroupProps
|
||||
extends InvertedProps,
|
||||
ReadOnlyProps,
|
||||
ErrorTextProps,
|
||||
ChipsGroupSizeProps,
|
||||
ChipsGroupLabelProps,
|
||||
ChipsGroupSingleLineProps,
|
||||
Pick<FieldContainerProps, 'gridTemplateColumns'> {
|
||||
/**
|
||||
* Обработчик события переключения элементов группы.
|
||||
*
|
||||
* @param value - Значение элемента группы.
|
||||
*/
|
||||
onChange?(value?: ChipsGroupValue): void;
|
||||
/**
|
||||
* Массив опций.
|
||||
*/
|
||||
options: ChipsOption[];
|
||||
/**
|
||||
* Значение поля (возможен единичный и мультивыбор).
|
||||
*/
|
||||
value?: ChipsGroupValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Свойства списка чипсов.
|
||||
*/
|
||||
export type ChipsListProps = Pick<
|
||||
ChipsGroupProps,
|
||||
'disabled' | 'inverted' | 'onChange' | 'options' | 'readOnly' | 'singleLine' | 'size' | 'value'
|
||||
>;
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { Title, Controls, Description, Source, Heading, Canvas, Subheading } from '@storybook/blocks';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { StoryCode } from 'common';
|
||||
import Chips from '..';
|
||||
import { AppearanceStory, SandboxStory, IconPositionStory, SizeStory, StatesStory } from './examples';
|
||||
|
||||
export { AppearanceStory, SandboxStory, IconPositionStory, SizeStory, StatesStory };
|
||||
SandboxStory.storyName = 'Песочница';
|
||||
SandboxStory.args = {
|
||||
disabled: false,
|
||||
inverted: false,
|
||||
checked: false,
|
||||
readOnly: false,
|
||||
size: 'M',
|
||||
children: 'Чипс',
|
||||
};
|
||||
SandboxStory.argTypes = {
|
||||
onClick: { action: 'onClick' },
|
||||
};
|
||||
AppearanceStory.storyName = 'Внешний вид';
|
||||
SizeStory.storyName = 'Размер компонента';
|
||||
IconPositionStory.storyName = 'Положение иконки';
|
||||
StatesStory.storyName = 'Состояния';
|
||||
StatesStory.parameters = {
|
||||
pseudo: {
|
||||
hover: '.pseudo-hover',
|
||||
},
|
||||
};
|
||||
|
||||
const Docs: React.FC = () => (
|
||||
<>
|
||||
<Title>Chips</Title>
|
||||
<Description of={Chips} />
|
||||
<Source
|
||||
code={`
|
||||
/** Импорт компонента Chips. */
|
||||
import { Chips } from '@msb/fractal-ui-composites';
|
||||
|
||||
<Chips icon={OkIcon}>Чипс</Chips>
|
||||
`}
|
||||
language="tsx"
|
||||
/>
|
||||
|
||||
<Heading>Внешний вид</Heading>
|
||||
<p>
|
||||
Внешний вид компонента может изменяться в зависимости от размера, наличия иконки или компонента <StoryCode>badge</StoryCode>.
|
||||
</p>
|
||||
<Canvas of={AppearanceStory} />
|
||||
|
||||
<Heading>Размер компонента</Heading>
|
||||
<p>
|
||||
Размер компонента зависит от свойства <StoryCode>size</StoryCode>. По умолчанию <StoryCode>M</StoryCode>.
|
||||
</p>
|
||||
<Canvas of={SizeStory} />
|
||||
|
||||
<Heading>Положение иконки</Heading>
|
||||
<p>
|
||||
Размер компонента зависит от свойства <StoryCode>iconPosition</StoryCode>.
|
||||
</p>
|
||||
<p>
|
||||
По умолчанию <StoryCode>left</StoryCode>.
|
||||
</p>
|
||||
<Canvas of={IconPositionStory} />
|
||||
|
||||
<Heading>Состояния</Heading>
|
||||
<p>
|
||||
Компонент в разных размерах и состояниях. Если <StoryCode>Chips</StoryCode> не выбран, то при ховере фон компонента меняет цвет.
|
||||
</p>
|
||||
<Canvas of={StatesStory} />
|
||||
|
||||
<Heading>Варианты использования</Heading>
|
||||
<p>
|
||||
Компонент <StoryCode>Chips</StoryCode> может использоваться для выбора одного варианта из группы.
|
||||
</p>
|
||||
<Source
|
||||
code={`
|
||||
<div style={{ display: 'flex' }}>
|
||||
<Chips checked size="S">0%</Chips>
|
||||
<Chips size="S">10%</Chips>
|
||||
<Chips size="S">20%</Chips>
|
||||
</div>
|
||||
`}
|
||||
language="tsx"
|
||||
/>
|
||||
|
||||
<p>
|
||||
Компонент <StoryCode>Chips</StoryCode> может использоваться в качестве фильтра для контента. В этом случае пользователь может выбрать
|
||||
несколько чипсов с разными категориями и отфильтровать контент.
|
||||
</p>
|
||||
<Source
|
||||
code={`
|
||||
<div style={{ display: 'flex' }}>
|
||||
<Chips checked size="S">Заявки</Chips>
|
||||
<Chips size="S">Договоры</Chips>
|
||||
<Chips checked size="S">Документы</Chips>
|
||||
<Chips size="S">Организации</Chips>
|
||||
<Chips size="S">Сотрудники</Chips>
|
||||
<Chips checked size="S">Письма</Chips>
|
||||
</div>
|
||||
`}
|
||||
language="tsx"
|
||||
/>
|
||||
|
||||
<Heading>Автотесты</Heading>
|
||||
<p>
|
||||
Компонент <StoryCode>Chips</StoryCode> имеет следующие атрибуты, описанные ниже.
|
||||
</p>
|
||||
<p>Все атрибуты всегда присутствуют в html-разметке. Значение атрибутов соответствуют значению пропсов.</p>
|
||||
|
||||
<Subheading>[data-role]</Subheading>
|
||||
<p>
|
||||
Все чипсы имеют значение <StoryCode>data-role=chips</StoryCode>.
|
||||
</p>
|
||||
<Subheading>[data-checked]</Subheading>
|
||||
<p>
|
||||
Значение атрибута заполняется из свойства <StoryCode>checked</StoryCode> компонента.
|
||||
</p>
|
||||
|
||||
<Heading>Песочница</Heading>
|
||||
<Canvas of={SandboxStory} />
|
||||
|
||||
<Heading>API</Heading>
|
||||
<Controls of={SandboxStory} />
|
||||
</>
|
||||
);
|
||||
|
||||
const StoryMeta: Meta = {
|
||||
title: 'Отображение данных/Chips',
|
||||
component: Chips,
|
||||
parameters: {
|
||||
docs: {
|
||||
page: Docs,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default StoryMeta;
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { type PropsWithChildren } from 'react';
|
||||
import { OkIcon } from '@fractal-ui/library';
|
||||
import { Wrapper } from '@msb/fractal-ui-styling';
|
||||
import type { StoryFn } from '@storybook/react';
|
||||
import type { BaseChipsProps } from '..';
|
||||
import Chips from '..';
|
||||
import type { ChipsProps } from '../types';
|
||||
|
||||
const ChipsTemplate = ({ children = 'Чипс', hover, ...props }: BaseChipsProps & { hover?: boolean }) => (
|
||||
<div style={{ display: 'flex', padding: '10px' }}>
|
||||
<Chips {...props}>{children}</Chips>
|
||||
</div>
|
||||
);
|
||||
|
||||
ChipsTemplate.displayName = 'ChipsTemplate';
|
||||
|
||||
export const SandboxStory: StoryFn<PropsWithChildren<ChipsProps>> = ({ inverted, ...args }) => (
|
||||
<Wrapper backgroundColor={inverted ? 'bg.secondary' : 'transparent'} display="flex">
|
||||
<ChipsTemplate {...args} icon={OkIcon} inverted={inverted} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
export const AppearanceStory: StoryFn<ChipsProps> = () => (
|
||||
<>
|
||||
<p>Без иконки</p>
|
||||
<ChipsTemplate />
|
||||
<p>С иконкой</p>
|
||||
<ChipsTemplate icon={OkIcon} />
|
||||
<p>С компонентом Badge</p>
|
||||
<ChipsTemplate value="34" />
|
||||
</>
|
||||
);
|
||||
|
||||
export const SizeStory: StoryFn<ChipsProps> = () => (
|
||||
<>
|
||||
<p>
|
||||
Размер <b>M</b>
|
||||
</p>
|
||||
<ChipsTemplate />
|
||||
<p>
|
||||
Размер <b>S</b>
|
||||
</p>
|
||||
<ChipsTemplate size="S" />
|
||||
</>
|
||||
);
|
||||
|
||||
export const IconPositionStory: StoryFn<ChipsProps> = () => (
|
||||
<>
|
||||
<p>
|
||||
Размер <b>M</b>, иконка расположена слева.
|
||||
</p>
|
||||
<ChipsTemplate icon={OkIcon} />
|
||||
<p>
|
||||
Размер <b>S</b>, иконка расположена слева.
|
||||
</p>
|
||||
<ChipsTemplate icon={OkIcon} size="S" />
|
||||
</>
|
||||
);
|
||||
|
||||
export const StatesStory: StoryFn<ChipsProps> = () => (
|
||||
<>
|
||||
<p>
|
||||
В размере <b>M</b>
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'space-around' }}>
|
||||
<div>
|
||||
<p>Default</p>
|
||||
<ChipsTemplate />
|
||||
<ChipsTemplate icon={OkIcon} />
|
||||
<ChipsTemplate value="34" />
|
||||
</div>
|
||||
<div>
|
||||
<p>Default Hover</p>
|
||||
<div>
|
||||
<ChipsTemplate hover className="pseudo-hover" />
|
||||
<ChipsTemplate hover className="pseudo-hover" icon={OkIcon} />
|
||||
<ChipsTemplate hover className="pseudo-hover" value="34" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Default Disabled</p>
|
||||
<ChipsTemplate disabled />
|
||||
<ChipsTemplate disabled icon={OkIcon} />
|
||||
<ChipsTemplate disabled value="34" />
|
||||
</div>
|
||||
<div>
|
||||
<p>Default ReadOnly</p>
|
||||
<ChipsTemplate readOnly />
|
||||
<ChipsTemplate readOnly icon={OkIcon} />
|
||||
<ChipsTemplate readOnly value="34" />
|
||||
</div>
|
||||
<div>
|
||||
<p>White</p>
|
||||
<Wrapper backgroundColor="bg.secondary">
|
||||
<ChipsTemplate inverted />
|
||||
<ChipsTemplate inverted icon={OkIcon} />
|
||||
<ChipsTemplate inverted value="34" />
|
||||
</Wrapper>
|
||||
</div>
|
||||
<div>
|
||||
<p>White Hover</p>
|
||||
<Wrapper backgroundColor="bg.secondary">
|
||||
<ChipsTemplate hover inverted className="pseudo-hover" />
|
||||
<ChipsTemplate hover inverted className="pseudo-hover" icon={OkIcon} />
|
||||
<ChipsTemplate hover inverted className="pseudo-hover" value="34" />
|
||||
</Wrapper>
|
||||
</div>
|
||||
<div>
|
||||
<p>White Disabled</p>
|
||||
<Wrapper backgroundColor="bg.secondary">
|
||||
<ChipsTemplate disabled inverted />
|
||||
<ChipsTemplate disabled inverted icon={OkIcon} />
|
||||
<ChipsTemplate disabled inverted value="34" />
|
||||
</Wrapper>
|
||||
</div>
|
||||
<div>
|
||||
<p>White ReadOnly</p>
|
||||
<Wrapper backgroundColor="bg.secondary">
|
||||
<ChipsTemplate inverted readOnly />
|
||||
<ChipsTemplate inverted readOnly icon={OkIcon} />
|
||||
<ChipsTemplate inverted readOnly value="34" />
|
||||
</Wrapper>
|
||||
</div>
|
||||
<div>
|
||||
<p>Active</p>
|
||||
<ChipsTemplate checked />
|
||||
<ChipsTemplate checked icon={OkIcon} />
|
||||
<ChipsTemplate checked value="34" />
|
||||
</div>
|
||||
<div>
|
||||
<p>Active Hover</p>
|
||||
<ChipsTemplate checked hover className="pseudo-hover" />
|
||||
<ChipsTemplate checked hover className="pseudo-hover" icon={OkIcon} />
|
||||
<ChipsTemplate checked hover className="pseudo-hover" value="34" />
|
||||
</div>
|
||||
<div>
|
||||
<p>Active Disabled</p>
|
||||
<ChipsTemplate checked disabled />
|
||||
<ChipsTemplate checked disabled icon={OkIcon} />
|
||||
<ChipsTemplate checked disabled value="34" />
|
||||
</div>
|
||||
<div>
|
||||
<p>Active ReadOnly</p>
|
||||
<ChipsTemplate checked readOnly />
|
||||
<ChipsTemplate checked readOnly icon={OkIcon} />
|
||||
<ChipsTemplate checked readOnly value="34" />
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<p>
|
||||
В размере <b>S</b>
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'space-around' }}>
|
||||
<div>
|
||||
<p>Default</p>
|
||||
<ChipsTemplate size="S" />
|
||||
<ChipsTemplate icon={OkIcon} size="S" />
|
||||
<ChipsTemplate size="S" value="34" />
|
||||
</div>
|
||||
<div>
|
||||
<p>Default Hover</p>
|
||||
<div>
|
||||
<ChipsTemplate hover className="pseudo-hover" size="S" />
|
||||
<ChipsTemplate hover className="pseudo-hover" icon={OkIcon} size="S" />
|
||||
<ChipsTemplate hover className="pseudo-hover" size="S" value="34" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Default Disabled</p>
|
||||
<ChipsTemplate disabled size="S" />
|
||||
<ChipsTemplate disabled icon={OkIcon} size="S" />
|
||||
<ChipsTemplate disabled size="S" value="34" />
|
||||
</div>
|
||||
<div>
|
||||
<p>Default ReadOnly</p>
|
||||
<ChipsTemplate readOnly size="S" />
|
||||
<ChipsTemplate readOnly icon={OkIcon} size="S" />
|
||||
<ChipsTemplate readOnly size="S" value="34" />
|
||||
</div>
|
||||
<div>
|
||||
<p>White</p>
|
||||
<Wrapper backgroundColor="bg.secondary">
|
||||
<ChipsTemplate inverted size="S" />
|
||||
<ChipsTemplate inverted icon={OkIcon} size="S" />
|
||||
<ChipsTemplate inverted size="S" value="34" />
|
||||
</Wrapper>
|
||||
</div>
|
||||
<div>
|
||||
<p>White Hover</p>
|
||||
<Wrapper backgroundColor="bg.secondary">
|
||||
<ChipsTemplate hover inverted className="pseudo-hover" size="S" />
|
||||
<ChipsTemplate hover inverted className="pseudo-hover" icon={OkIcon} size="S" />
|
||||
<ChipsTemplate hover inverted className="pseudo-hover" size="S" value="34" />
|
||||
</Wrapper>
|
||||
</div>
|
||||
<div>
|
||||
<p>White Disabled</p>
|
||||
<Wrapper backgroundColor="bg.secondary">
|
||||
<ChipsTemplate disabled inverted size="S" />
|
||||
<ChipsTemplate disabled inverted icon={OkIcon} size="S" />
|
||||
<ChipsTemplate disabled inverted size="S" value="34" />
|
||||
</Wrapper>
|
||||
</div>
|
||||
<div>
|
||||
<p>White ReadOnly</p>
|
||||
<Wrapper backgroundColor="bg.secondary">
|
||||
<ChipsTemplate inverted readOnly size="S" />
|
||||
<ChipsTemplate inverted readOnly icon={OkIcon} size="S" />
|
||||
<ChipsTemplate inverted readOnly size="S" value="34" />
|
||||
</Wrapper>
|
||||
</div>
|
||||
<div>
|
||||
<p>Active</p>
|
||||
<ChipsTemplate checked size="S" />
|
||||
<ChipsTemplate checked icon={OkIcon} size="S" />
|
||||
<ChipsTemplate checked size="S" value="34" />
|
||||
</div>
|
||||
<div>
|
||||
<p>Active Hover</p>
|
||||
<ChipsTemplate checked hover className="pseudo-hover" size="S" />
|
||||
<ChipsTemplate checked hover className="pseudo-hover" icon={OkIcon} size="S" />
|
||||
<ChipsTemplate checked hover className="pseudo-hover" size="S" value="34" />
|
||||
</div>
|
||||
<div>
|
||||
<p>Active Disabled</p>
|
||||
<ChipsTemplate checked disabled size="S" />
|
||||
<ChipsTemplate checked disabled icon={OkIcon} size="S" />
|
||||
<ChipsTemplate checked disabled size="S" value="34" />
|
||||
</div>
|
||||
<div>
|
||||
<p>Active ReadOnly</p>
|
||||
<ChipsTemplate checked readOnly size="S" />
|
||||
<ChipsTemplate checked readOnly icon={OkIcon} size="S" />
|
||||
<ChipsTemplate checked readOnly size="S" value="34" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import Chips from '..';
|
||||
|
||||
describe('Chips Autotest', () => {
|
||||
const query = '[data-role="chips"]';
|
||||
|
||||
it('имеет атрибут [data-role="chips"]', async () => {
|
||||
const { container } = render(<Chips />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(query)).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('имеет атрибут [data-checked="true"]', async () => {
|
||||
const { container } = render(<Chips checked />);
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll(`${query}[data-checked="true"]`)).toHaveLength(1));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { OkIcon } from '@fractal-ui/library';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import Chips from '..';
|
||||
|
||||
const labelText = 'chips-component';
|
||||
|
||||
describe('Chips', () => {
|
||||
it('проверяет наличие лейбла', async () => {
|
||||
render(<Chips>{labelText}</Chips>);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(labelText)).toHaveTextContent(labelText));
|
||||
});
|
||||
|
||||
it('проверяет наличие иконки', async () => {
|
||||
render(<Chips icon={OkIcon}>{labelText}</Chips>);
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('icon')).toBeDefined());
|
||||
});
|
||||
|
||||
it('компонент отрисован без иконки', async () => {
|
||||
render(<Chips>{labelText}</Chips>);
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('icon')).toBeNull());
|
||||
});
|
||||
|
||||
it('компонент отрисован с badge', async () => {
|
||||
render(<Chips value={'123'}>{labelText}</Chips>);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('123')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('проверяет, что обработчик был вызван 1 раз', async () => {
|
||||
const handleClick = jest.fn();
|
||||
|
||||
render(<Chips onClick={handleClick}>{labelText}</Chips>);
|
||||
|
||||
userEvent.click(screen.getByTestId('chips'));
|
||||
await waitFor(() => expect(handleClick).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('проверяет, что обработчик не был вызван в disabled состоянии', async () => {
|
||||
const handleClick = jest.fn();
|
||||
|
||||
render(
|
||||
<Chips disabled onClick={handleClick}>
|
||||
{labelText}
|
||||
</Chips>
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('chips'));
|
||||
await waitFor(() => expect(handleClick).toHaveBeenCalledTimes(0));
|
||||
});
|
||||
|
||||
it('проверяет, что обработчик не был вызван в readOnly состоянии', async () => {
|
||||
const handleClick = jest.fn();
|
||||
|
||||
render(
|
||||
<Chips readOnly onClick={handleClick}>
|
||||
{labelText}
|
||||
</Chips>
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('chips'));
|
||||
await waitFor(() => expect(handleClick).toHaveBeenCalledTimes(0));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Цвет компонента BadgeChips. */
|
||||
export const BG_COLOR: Record<string, string> = {
|
||||
active: 'control.inversed.bgPressed',
|
||||
default: 'control.secondary.grey.bg',
|
||||
};
|
||||
|
||||
/** Цвет для текста компонента BadgeChips. */
|
||||
export const TEXT_COLOR: Record<string, string> = {
|
||||
active: 'control.inversed.typo',
|
||||
default: 'text.secondary',
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import type { Theme } from '@emotion/react';
|
||||
import { ROLE } from '@msb/fractal-ui-core';
|
||||
import type { StyledComponentProps } from '@msb/fractal-ui-styling';
|
||||
import { Text, Wrapper } from '@msb/fractal-ui-styling';
|
||||
import { BG_COLOR, TEXT_COLOR } from './constants';
|
||||
import type { BadgeChipsProps } from './types';
|
||||
|
||||
const bageStyles = {
|
||||
height: 'auto',
|
||||
minHeight: 'badge.XS',
|
||||
pr: 'badge.XS',
|
||||
pl: 'badge.XS',
|
||||
borderRadius: 'badge.XS',
|
||||
alignItems: 'center',
|
||||
display: 'inline-flex',
|
||||
};
|
||||
|
||||
/**
|
||||
* Базовый тип пропсов компонента BadgeChips.
|
||||
*/
|
||||
export type BaseBadgeChipsProps = StyledComponentProps<'div', Theme, BadgeChipsProps, never>;
|
||||
|
||||
/**
|
||||
* Компонент BadgeChips - кастомный компонент на основе Badge, для применения внутри Chips.
|
||||
*/
|
||||
export const BadgeChips: React.FC<BaseBadgeChipsProps> = ({ children, disabled, type = 'default', ...props }) => {
|
||||
/** Цвет заднего фона Badge`a. */
|
||||
const bgColor = disabled ? 'control.disabled.border' : BG_COLOR[type];
|
||||
/** Цвет текста Badge`a. */
|
||||
const textColor = disabled ? 'control.disabled.typo' : TEXT_COLOR[type];
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
{...bageStyles}
|
||||
{...props}
|
||||
backgroundColor={bgColor}
|
||||
boxSizing="border-box"
|
||||
data-disabled={disabled}
|
||||
data-role={ROLE.BADGE}
|
||||
role={ROLE.BADGE}
|
||||
>
|
||||
<Text.P4 color={textColor} mt="badgeChips.autoHeight.text.XS" wordBreak="break-word">
|
||||
{children}
|
||||
</Text.P4>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
BadgeChips.displayName = 'BadgeChips';
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { BaseBadgeProps } from '@msb/fractal-ui-extended';
|
||||
|
||||
/**
|
||||
* Семантическая палитра компонента BadgeChips.
|
||||
*/
|
||||
export type BadgeChipsType = 'active' | 'default';
|
||||
|
||||
/**
|
||||
* Тип пропсов компонента BadgeChips.
|
||||
*/
|
||||
export interface BadgeChipsProps extends Pick<BaseBadgeProps, 'disabled'> {
|
||||
/**
|
||||
* Тип BadgeChips, определяющий цвет компонента.
|
||||
*/
|
||||
type: BadgeChipsType;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BadgeChips } from './badge-chips';
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import type { Theme } from '@emotion/react';
|
||||
import { ROLE } from '@msb/fractal-ui-core';
|
||||
import { Text, Wrapper } from '@msb/fractal-ui-styling';
|
||||
import type { StyledComponentProps } from '@msb/fractal-ui-styling';
|
||||
import { getIconAndTextColor, StyledBadgeChips, StyledBox } from './styled';
|
||||
import type { ChipsProps } from './types';
|
||||
|
||||
/**
|
||||
* Тип, описывающие базовые пропы компонента Chips.
|
||||
*/
|
||||
export type BaseChipsProps = StyledComponentProps<'div', Theme, ChipsProps, never>;
|
||||
|
||||
/**
|
||||
* Компонент Chips.
|
||||
*
|
||||
* Чипсы группируют контент и помогают в навигации. Используются для показа примененных фильтров.
|
||||
*
|
||||
* @see https://www.figma.com/file/CizcXMEqxBSENC0hXJATi3/?node-id=1098%3A44287.
|
||||
*/
|
||||
const Chips: React.FC<BaseChipsProps> = ({
|
||||
value,
|
||||
inverted,
|
||||
checked,
|
||||
disabled,
|
||||
readOnly,
|
||||
size = 'M',
|
||||
icon: Icon,
|
||||
onClick,
|
||||
children,
|
||||
...rest
|
||||
}) => {
|
||||
const sizeValue = `chips.${String(size)}`;
|
||||
|
||||
const paddingLeft = `chips.${String(size)}.default`;
|
||||
let paddingRight = `chips.${String(size)}.default`;
|
||||
|
||||
if (Icon) {
|
||||
paddingRight = `chips.${String(size)}.icon`;
|
||||
}
|
||||
|
||||
const StyledText = size === 'M' ? Text.P2 : Text.P3;
|
||||
|
||||
return (
|
||||
<StyledBox
|
||||
{...rest}
|
||||
borderRadius={sizeValue}
|
||||
checked={checked}
|
||||
data-checked={checked}
|
||||
data-role={ROLE.CHIPS}
|
||||
data-testid="chips"
|
||||
disabled={disabled}
|
||||
height={sizeValue}
|
||||
inverted={inverted}
|
||||
minHeight={sizeValue}
|
||||
pl={paddingLeft}
|
||||
pr={paddingRight}
|
||||
readOnly={readOnly}
|
||||
onClick={!disabled && !readOnly && onClick ? onClick : undefined}
|
||||
>
|
||||
{Icon && (
|
||||
<Wrapper color={getIconAndTextColor(checked, disabled, readOnly)} data-testid="icon" display="inline-flex" mr="chips.icon">
|
||||
<Icon size={size} />
|
||||
</Wrapper>
|
||||
)}
|
||||
<StyledText as="span" color={getIconAndTextColor(checked, disabled, readOnly)}>
|
||||
{children}
|
||||
</StyledText>
|
||||
{value && (
|
||||
<StyledBadgeChips disabled={disabled || readOnly} ml={`chips.${String(size)}.badge`} type={checked ? 'active' : 'default'}>
|
||||
{value}
|
||||
</StyledBadgeChips>
|
||||
)}
|
||||
</StyledBox>
|
||||
);
|
||||
};
|
||||
|
||||
Chips.displayName = 'Chips';
|
||||
|
||||
export default Chips;
|
||||
@@ -0,0 +1,91 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Wrapper } from '@msb/fractal-ui-styling';
|
||||
import type { CheckedProps, DisabledProps, InvertedProps, ReadOnlyProps } from '@msb/fractal-ui-styling';
|
||||
import styledCss from '@styled-system/css';
|
||||
import { space } from 'styled-system';
|
||||
import type { SpaceProps } from 'styled-system';
|
||||
import { BadgeChips } from './components';
|
||||
|
||||
/** Цвет фона компонента Chips. */
|
||||
const getBgColor = ({ checked, disabled, inverted, readOnly }: CheckedProps & DisabledProps & InvertedProps & ReadOnlyProps) => {
|
||||
if ((disabled || readOnly) && checked) return 'control.disabled.element';
|
||||
|
||||
if ((disabled || readOnly) && inverted) return 'bg.primary';
|
||||
|
||||
switch (true) {
|
||||
case checked:
|
||||
return 'control.inversed.bg';
|
||||
case disabled || readOnly:
|
||||
return 'bg.tertiary';
|
||||
case inverted:
|
||||
return 'bg.primary';
|
||||
default:
|
||||
return 'bg.secondary';
|
||||
}
|
||||
};
|
||||
|
||||
/** Вариант курсора при наведении на компонент Chips. */
|
||||
const getCursor = ({ disabled, readOnly }: DisabledProps & ReadOnlyProps) => {
|
||||
switch (true) {
|
||||
case disabled:
|
||||
return 'not-allowed';
|
||||
case readOnly:
|
||||
return 'text';
|
||||
default:
|
||||
return 'pointer';
|
||||
}
|
||||
};
|
||||
|
||||
/** Стилизованный компонент Chips. */
|
||||
export const StyledBox = styled(Wrapper)(
|
||||
styledCss({
|
||||
boxSizing: 'border-box',
|
||||
}),
|
||||
({ checked, inverted, disabled, readOnly }) => {
|
||||
let hoverAction;
|
||||
let hoverBgColor;
|
||||
|
||||
if (!disabled) {
|
||||
hoverBgColor = checked ? 'bg.system' : 'bg.tertiary';
|
||||
}
|
||||
|
||||
if (!readOnly) {
|
||||
hoverAction = {
|
||||
'&:hover': {
|
||||
backgroundColor: hoverBgColor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return styledCss({
|
||||
cursor: getCursor({ disabled, readOnly }),
|
||||
backgroundColor: getBgColor({ checked, disabled, inverted, readOnly }),
|
||||
transition: 'background-color 0.3s ease 0s, color 0.3s',
|
||||
|
||||
...hoverAction,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
StyledBox.defaultProps = {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
};
|
||||
|
||||
/** Цвет иконки компонента Chips. */
|
||||
export const getIconAndTextColor = (checked?: boolean, disabled?: boolean, readOnly?: boolean) => {
|
||||
switch (true) {
|
||||
case disabled || (!checked && readOnly):
|
||||
return 'control.disabled.typo';
|
||||
case checked && !readOnly:
|
||||
return 'control.inversed.typo';
|
||||
case checked && readOnly:
|
||||
return 'text.primary';
|
||||
default:
|
||||
return 'text.primary';
|
||||
}
|
||||
};
|
||||
|
||||
/** Компонент BadgeChips с возможностью прокидывать пропы для отступов. */
|
||||
export const StyledBadgeChips = styled(BadgeChips)<SpaceProps>(space);
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { IconComponent } from '@fractal-ui/library';
|
||||
import type { SizeM, SizeS, HorizontalPosition, DisabledProps, CheckedProps, InvertedProps, ReadOnlyProps } from '@msb/fractal-ui-styling';
|
||||
import type { FlexboxProps, WidthProps } from 'styled-system';
|
||||
|
||||
/**
|
||||
* Размер компонента Chips.
|
||||
*/
|
||||
export type ChipsSize = SizeM | SizeS;
|
||||
|
||||
/**
|
||||
* Сторона расположения иконки на компоненте.
|
||||
*/
|
||||
export type ChipsIconPosition = HorizontalPosition;
|
||||
|
||||
/**
|
||||
* Интерфейс, описывающий состояние компонента.
|
||||
*/
|
||||
export type ChipsStateProps = CheckedProps & DisabledProps & InvertedProps & ReadOnlyProps;
|
||||
|
||||
/**
|
||||
* Свойства компонента Chips.
|
||||
*/
|
||||
export interface ChipsProps extends ChipsStateProps, FlexboxProps, WidthProps {
|
||||
/**
|
||||
* Обработчик нажатия на компонент.
|
||||
*/
|
||||
onClick?(): void;
|
||||
/**
|
||||
* Размер компонента.
|
||||
*
|
||||
* По умолчанию размер M.
|
||||
*/
|
||||
size?: ChipsSize;
|
||||
/**
|
||||
* Компонент-иконка, отображаемый в компоненте.
|
||||
*/
|
||||
icon?: IconComponent;
|
||||
/**
|
||||
* Сторона расположения иконки на компоненте относительно лейбла.
|
||||
*
|
||||
* По умолчанию расположена с левой стороны.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
iconPosition?: ChipsIconPosition;
|
||||
/**
|
||||
* Значение, которое будет отображаться в компоненте Badge.
|
||||
*/
|
||||
value?: string;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Title, Controls, Description, Source, Heading, Canvas } from '@storybook/blocks';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { StoryCode } from 'common';
|
||||
import CodeInput from '..';
|
||||
import { SandboxStory, SizesStory, VariantsStory } from './examples';
|
||||
|
||||
export { SandboxStory, SizesStory, VariantsStory };
|
||||
SandboxStory.storyName = 'Песочница';
|
||||
SandboxStory.args = {
|
||||
name: 'CodeInput',
|
||||
};
|
||||
VariantsStory.storyName = 'Количество ячеек для ввода';
|
||||
SizesStory.storyName = 'Размер';
|
||||
|
||||
const Docs: React.FC = () => (
|
||||
<>
|
||||
<Title>CodeInput</Title>
|
||||
<Description of={CodeInput} />
|
||||
<h4>Примечание:</h4>
|
||||
<ol>
|
||||
<li>Компонент поддерживает вставку значения из буфера обмена (значение вставляется начиная с ячейки в фокусе);</li>
|
||||
<li>При клике на ячейку происходит выделение значения в ней;</li>
|
||||
<li>Если стирается единственная ячейка со значением, то фокус перемещается на первую.</li>
|
||||
</ol>
|
||||
<Source
|
||||
code={`
|
||||
// импорт компонента CodeInput
|
||||
import { CodeInput } from '@msb/fractal-ui-composites';
|
||||
|
||||
// Компонент для ввода кода из смс.
|
||||
<CodeInput digitsQty={6} onChange={() => {}} name='sms-code' groupBy={3} />
|
||||
`}
|
||||
language="tsx"
|
||||
/>
|
||||
|
||||
<Heading>Количество ячеек</Heading>
|
||||
<p>
|
||||
Количество ячеек для ввода задается пропом digitsQty (дефолтное значение 6). При этом важно указать проп groupBy (дефолтное значение
|
||||
3) для настройки отступов между группами.
|
||||
</p>
|
||||
|
||||
<Canvas of={VariantsStory} />
|
||||
|
||||
<Heading>Размер</Heading>
|
||||
<p>
|
||||
Поле имеет 2 размера и задается свойством <StoryCode>size</StoryCode>, которое может принимать значения <StoryCode>L, M</StoryCode>.
|
||||
Под размер <StoryCode>L</StoryCode> предусмотрены ширина и высота по 48px, а в размере <StoryCode>M</StoryCode> по 40px. По умолчанию
|
||||
размер <StoryCode>L</StoryCode>.
|
||||
</p>
|
||||
|
||||
<Canvas of={SizesStory} />
|
||||
|
||||
<Heading>Песочница</Heading>
|
||||
<Canvas of={SandboxStory} />
|
||||
|
||||
<Heading>API</Heading>
|
||||
<Controls of={SandboxStory} />
|
||||
</>
|
||||
);
|
||||
|
||||
const StoryMeta: Meta = {
|
||||
title: 'Компоненты формы/CodeInput',
|
||||
component: CodeInput,
|
||||
parameters: {
|
||||
docs: {
|
||||
page: Docs,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default StoryMeta;
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import type { FieldComponents } from '@msb/fractal-ui-form';
|
||||
import { Fields } from '@msb/fractal-ui-form';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { useArgs } from '@storybook/preview-api';
|
||||
import type { StoryFn } from '@storybook/react';
|
||||
import { SandboxField, SandboxStoryForm } from 'common';
|
||||
import CodeInput from '..';
|
||||
import type { CodeInputPropsType } from '../types';
|
||||
|
||||
const CodeInputTemplate: React.VFC<CodeInputPropsType> = ({ onChange, value, ...props }) => {
|
||||
const [digits, setDigits] = useState(value);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
action('onChange');
|
||||
setDigits(newValue);
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
return <CodeInput {...props} value={digits} onChange={handleChange} />;
|
||||
};
|
||||
|
||||
CodeInputTemplate.displayName = 'CodeInputTemplate';
|
||||
|
||||
export const SandboxStory: StoryFn<CodeInputPropsType> = args => {
|
||||
const [_, updateArgs] = useArgs();
|
||||
|
||||
const handleChange: CodeInputPropsType['onChange'] = value => {
|
||||
action('onChange')(value);
|
||||
updateArgs({ ...args, value });
|
||||
};
|
||||
|
||||
const onChangeField: ComponentProps<FieldComponents['CodeInput']>['onChange'] = ({ value }) => handleChange(value);
|
||||
|
||||
return (
|
||||
<SandboxStoryForm
|
||||
fieldComponent={
|
||||
<SandboxField<'CodeInput'>
|
||||
Field={Fields.CodeInput}
|
||||
{...(args as ComponentProps<FieldComponents['CodeInput']>)}
|
||||
onChange={onChangeField}
|
||||
/>
|
||||
}
|
||||
fieldType="CodeInput"
|
||||
mainComponent={<CodeInput {...args} onChange={handleChange} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const VariantsStory: StoryFn<CodeInputPropsType> = args => (
|
||||
<>
|
||||
<h4> Стандарт - 6 ячеек, 2 группы по 3 </h4>
|
||||
<CodeInputTemplate {...args} />
|
||||
<br />
|
||||
<h4> 3 ячейки, без группировки </h4>
|
||||
<CodeInputTemplate {...args} digitsQty={3} groupBy={0} />
|
||||
<br />
|
||||
<h4> 4 ячейки, 2 группы по 2 </h4>
|
||||
<CodeInputTemplate {...args} digitsQty={4} groupBy={2} />
|
||||
<br />
|
||||
<h4> 5 ячеек без группировки </h4>
|
||||
<CodeInputTemplate {...args} digitsQty={5} groupBy={0} />
|
||||
<br />
|
||||
</>
|
||||
);
|
||||
|
||||
export const SizesStory: StoryFn<CodeInputPropsType> = args => (
|
||||
<>
|
||||
<h4> Размер L </h4>
|
||||
<CodeInputTemplate {...args} />
|
||||
<br />
|
||||
<h4> Размер M </h4>
|
||||
<CodeInputTemplate {...args} size="M" />
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { ROLE } from '@msb/fractal-ui-core';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import CodeInput from '..';
|
||||
|
||||
const name = 'test-input';
|
||||
const defaultDigitsQty = 6;
|
||||
|
||||
jest.mock('../../../../styling/src/hooks/use-breakpoints');
|
||||
|
||||
describe('CodeInput', () => {
|
||||
test('компонент должен рендерить количество инпутов равное значению digitsQty', async () => {
|
||||
const digitsQty = 5;
|
||||
|
||||
render(<CodeInput digitsQty={digitsQty} name={name} onChange={() => {}} />);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByTestId(ROLE.INPUT)).toHaveLength(digitsQty));
|
||||
});
|
||||
|
||||
test('если проп value не передан, то дефолтное значение будет ""', async () => {
|
||||
render(<CodeInput digitsQty={3} name={name} onChange={() => {}} />);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByTestId(ROLE.INPUT)[0] as HTMLInputElement).toHaveValue(''));
|
||||
});
|
||||
|
||||
test('компонент должен рендерить стандартное количество инпутов, равное шести', async () => {
|
||||
render(<CodeInput name={name} onChange={() => {}} />);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByTestId(ROLE.INPUT)).toHaveLength(defaultDigitsQty));
|
||||
});
|
||||
|
||||
test('при изменении значения вызывается колбэк onChange', async () => {
|
||||
const newValue = '9';
|
||||
const changeHandler = jest.fn();
|
||||
|
||||
render(<CodeInput digitsQty={3} name={name} onChange={changeHandler} />);
|
||||
|
||||
await waitFor(() => expect(changeHandler).not.toHaveBeenCalled());
|
||||
fireEvent.change(screen.getAllByTestId(ROLE.INPUT)[0], { target: { value: newValue } });
|
||||
|
||||
await waitFor(() => expect(changeHandler).toHaveBeenCalledWith(`${newValue}__`));
|
||||
|
||||
fireEvent.change(screen.getAllByTestId(ROLE.INPUT)[1], { target: { value: newValue } });
|
||||
await waitFor(() => expect(changeHandler).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
test('при попытке измененить значение на букву колбэк onChange не вызывается', async () => {
|
||||
const newValue = 'test';
|
||||
const changeHandler = jest.fn();
|
||||
|
||||
render(<CodeInput digitsQty={3} name={name} onChange={changeHandler} />);
|
||||
|
||||
await waitFor(() => expect(changeHandler).not.toHaveBeenCalled());
|
||||
fireEvent.change(screen.getAllByTestId(ROLE.INPUT)[0], { target: { value: newValue } });
|
||||
|
||||
await waitFor(() => expect(changeHandler).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test('при удалении значения вызывается колбэк onChange', async () => {
|
||||
const changeHandler = jest.fn();
|
||||
|
||||
render(<CodeInput digitsQty={3} name={name} value={'154'} onChange={changeHandler} />);
|
||||
|
||||
fireEvent.focus(screen.getAllByTestId(ROLE.INPUT)[1]);
|
||||
fireEvent.keyDown(screen.getAllByTestId(ROLE.INPUT)[1], { key: 'Backspace', code: 'Backspace' });
|
||||
|
||||
await waitFor(() => expect(changeHandler).toHaveBeenCalledWith('1_4'));
|
||||
});
|
||||
|
||||
test('при нажатии Backspace на пустом инпуте колбэк onChange не вызывается', async () => {
|
||||
const changeHandler = jest.fn();
|
||||
|
||||
render(<CodeInput digitsQty={3} name={name} value={'_1_'} onChange={changeHandler} />);
|
||||
|
||||
fireEvent.focus(screen.getAllByTestId(ROLE.INPUT)[1]);
|
||||
fireEvent.keyDown(screen.getAllByTestId(ROLE.INPUT)[1], { key: 'Backspace', code: 'Backspace' });
|
||||
|
||||
await waitFor(() => expect(changeHandler).not.toHaveBeenCalledWith());
|
||||
});
|
||||
|
||||
test('при нажатии Backspace с пустым value происходит фокус на первом поле', async () => {
|
||||
const changeHandler = jest.fn();
|
||||
|
||||
render(<CodeInput digitsQty={3} name={name} value={'___'} onChange={changeHandler} />);
|
||||
|
||||
fireEvent.focus(screen.getAllByTestId(ROLE.INPUT)[2]);
|
||||
fireEvent.keyDown(screen.getAllByTestId(ROLE.INPUT)[2], { key: 'Backspace', code: 'Backspace' });
|
||||
|
||||
await waitFor(() => expect(changeHandler).not.toHaveBeenCalledWith());
|
||||
});
|
||||
|
||||
test('при удалении последнего значения фокус переносится на первый инпут', async () => {
|
||||
const changeHandler = jest.fn();
|
||||
|
||||
render(<CodeInput digitsQty={3} name={name} value={'__1'} onChange={changeHandler} />);
|
||||
|
||||
fireEvent.focus(screen.getAllByTestId(ROLE.INPUT)[2]);
|
||||
fireEvent.keyDown(screen.getAllByTestId(ROLE.INPUT)[2], { key: 'Backspace', code: 'Backspace' });
|
||||
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getAllByTestId(ROLE.INPUT)[0]));
|
||||
});
|
||||
|
||||
test('инпут попадает в фокус при клике', async () => {
|
||||
render(<CodeInput digitsQty={3} name={name} value={'__1'} onChange={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getAllByTestId(ROLE.INPUT)[0]);
|
||||
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getAllByTestId(ROLE.INPUT)[0]));
|
||||
});
|
||||
|
||||
test('при указании только последней цифры, вместо первых указываются нижние подчеркивания', async () => {
|
||||
const changeHandler = jest.fn();
|
||||
|
||||
render(<CodeInput digitsQty={3} name={name} value={''} onChange={changeHandler} />);
|
||||
|
||||
fireEvent.change(screen.getAllByTestId(ROLE.INPUT)[2], { target: { value: '1' } });
|
||||
|
||||
await waitFor(() => expect(changeHandler).toHaveBeenCalledWith('__1'));
|
||||
});
|
||||
|
||||
test('при вставке в первое поле значения из трех цифр, значение заполняет поля начиная с текущего поля', async () => {
|
||||
const changeHandler = jest.fn();
|
||||
|
||||
render(<CodeInput digitsQty={3} name={name} value={''} onChange={changeHandler} />);
|
||||
|
||||
fireEvent.paste(screen.getAllByTestId(ROLE.INPUT)[0], { clipboardData: { getData: () => '123' } });
|
||||
|
||||
await waitFor(() => expect(changeHandler).toHaveBeenCalledWith('123'));
|
||||
});
|
||||
|
||||
test('при вставке из буфера обмена в первое поле значения из трех цифр, значение заполняет поля начиная с текущего поля', async () => {
|
||||
const changeHandler = jest.fn();
|
||||
|
||||
render(<CodeInput digitsQty={3} name={name} value={''} onChange={changeHandler} />);
|
||||
|
||||
fireEvent.change(screen.getAllByTestId(ROLE.INPUT)[0], { target: { value: '111' } });
|
||||
|
||||
await waitFor(() => expect(changeHandler).toHaveBeenCalledWith('111'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { KeyboardEvent, ClipboardEvent, FocusEvent } from 'react';
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { ROLE } from '@msb/fractal-ui-core';
|
||||
import FieldContainer from '../field-container';
|
||||
import Input from '../input';
|
||||
import InputFooter from '../input/footer';
|
||||
import { INPUT_TEXT_PROPS, MARGIN_FROM_INPUT } from '../input/helpers';
|
||||
import { ContentContainer, StyledTypography } from '../input/styled';
|
||||
import { CodeInputWrapper } from './styled';
|
||||
import type { CodeInputPropsType } from './types';
|
||||
|
||||
/**
|
||||
* Компонент для ввода кода из СМС.
|
||||
*/
|
||||
const CodeInput: React.FC<CodeInputPropsType> = ({
|
||||
digitsQty = 6,
|
||||
value,
|
||||
onChange,
|
||||
name,
|
||||
groupBy = 3,
|
||||
hasError,
|
||||
errorText,
|
||||
disabled,
|
||||
size = 'L',
|
||||
autoComplete = 'off',
|
||||
autoCorrect = 'off',
|
||||
}) => {
|
||||
const digits: string[] = value ? Array.from(Array(digitsQty), (item, index) => value[index] ?? '_') : Array(digitsQty).fill('_');
|
||||
|
||||
const inputRefs = useRef<HTMLInputElement[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRefs.current[0].focus();
|
||||
}, []);
|
||||
|
||||
const handleChange = (index: number, newValue: number | string | undefined) => {
|
||||
const newValueString = newValue?.toString();
|
||||
|
||||
if (newValueString && newValueString.length === digitsQty) {
|
||||
if (isNaN(Number(newValueString))) return;
|
||||
|
||||
const newDigits = [...digits];
|
||||
|
||||
for (let i = 0; i < digitsQty; i++) {
|
||||
if (newValueString[i]) {
|
||||
newDigits[i] = newValueString[i];
|
||||
}
|
||||
}
|
||||
|
||||
onChange?.(newDigits.join(''));
|
||||
|
||||
if (index < digitsQty - 1) {
|
||||
inputRefs.current[digitsQty - 1]?.focus();
|
||||
}
|
||||
} else if (newValueString) {
|
||||
const oldDigit = digits[index];
|
||||
|
||||
const newDigit = newValueString.trim().replace(oldDigit, '');
|
||||
|
||||
if (newDigit.length > 1 || isNaN(Number(newDigit))) return;
|
||||
|
||||
const newDigits = [...digits];
|
||||
|
||||
newDigits[index] = newDigit;
|
||||
|
||||
onChange?.(newDigits.join(''));
|
||||
|
||||
if (index < digitsQty - 1) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (index: number, event: KeyboardEvent): void => {
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
|
||||
if (digits[index].match(/^\d$/)) {
|
||||
const newDigits = [...digits];
|
||||
|
||||
newDigits[index] = '_';
|
||||
|
||||
if (index > 0) {
|
||||
if (newDigits.every(i => i === '_')) {
|
||||
inputRefs.current[0].focus();
|
||||
} else {
|
||||
inputRefs.current[index - 1].focus();
|
||||
}
|
||||
}
|
||||
|
||||
onChange?.(newDigits.join(''));
|
||||
} else if (index > 0) {
|
||||
digits.every(i => i === '_') ? inputRefs.current[0].focus() : inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = (event: FocusEvent<HTMLInputElement>) => event.target.select();
|
||||
|
||||
const handlePaste = (index: number, event: ClipboardEvent) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const pastedText = event.clipboardData.getData('Text');
|
||||
|
||||
if (!pastedText || isNaN(Number(pastedText))) return;
|
||||
|
||||
const newDigits = [...digits];
|
||||
|
||||
for (let i = index; i < digitsQty; i++) {
|
||||
if (pastedText[i - index]) {
|
||||
newDigits[i] = pastedText[i - index];
|
||||
}
|
||||
}
|
||||
|
||||
onChange?.(newDigits.join(''));
|
||||
inputRefs.current[index + pastedText.length >= digitsQty ? digitsQty - 1 : index + pastedText.length].focus();
|
||||
};
|
||||
|
||||
const footer = useMemo(
|
||||
() =>
|
||||
Boolean(errorText) && (
|
||||
<ContentContainer>
|
||||
<StyledTypography
|
||||
data-name={name}
|
||||
data-role={ROLE.DESCRIPTION}
|
||||
lineHeight={`input.L`}
|
||||
mb="0px"
|
||||
mt={MARGIN_FROM_INPUT.L}
|
||||
size={'L'}
|
||||
>
|
||||
<InputFooter disabled={disabled} errorText={errorText} hasError={hasError} />
|
||||
</StyledTypography>
|
||||
</ContentContainer>
|
||||
),
|
||||
[errorText, name, disabled, hasError]
|
||||
);
|
||||
|
||||
return (
|
||||
<FieldContainer helperText={footer}>
|
||||
<CodeInputWrapper data-name={name} display="inline-flex" groupBy={groupBy}>
|
||||
{digits.map((digit, index) => (
|
||||
<Input
|
||||
/* eslint-disable-next-line react/no-array-index-key */
|
||||
key={`digit-${index}`}
|
||||
ref={element => {
|
||||
element && (inputRefs.current[index] = element);
|
||||
}}
|
||||
autoComplete={index === 0 ? autoComplete : 'off'}
|
||||
autoCorrect={index === 0 ? autoCorrect : 'off'}
|
||||
clearButtonVisibility={'never'}
|
||||
data-disabled={disabled}
|
||||
dataRole={ROLE.INPUT}
|
||||
disabled={disabled}
|
||||
hasError={hasError}
|
||||
name={`digit-${index}`}
|
||||
size={size}
|
||||
{...INPUT_TEXT_PROPS.L}
|
||||
type="tel"
|
||||
value={digit === '_' ? '' : digit}
|
||||
width={`input.${size}`}
|
||||
onChange={newValue => handleChange(index, newValue)}
|
||||
onClick={() => inputRefs.current[index]?.focus()}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={event => handleKeyDown(index, event)}
|
||||
onPaste={event => handlePaste(index, event)}
|
||||
/>
|
||||
))}
|
||||
</CodeInputWrapper>
|
||||
</FieldContainer>
|
||||
);
|
||||
};
|
||||
|
||||
CodeInput.displayName = 'CodeInput';
|
||||
|
||||
export default CodeInput;
|
||||
@@ -0,0 +1,29 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Wrapper } from '@msb/fractal-ui-styling';
|
||||
import styledCss from '@styled-system/css';
|
||||
import type { CodeInputPropsType } from './types';
|
||||
|
||||
/**
|
||||
* Стилизованный компонент обертка для поля ввода кода из СМС.
|
||||
*/
|
||||
export const CodeInputWrapper = styled(Wrapper)<Pick<CodeInputPropsType, 'groupBy'>>(
|
||||
styledCss({
|
||||
'>div:not(div:last-child)': {
|
||||
marginRight: 2,
|
||||
},
|
||||
'div:has(>input)': {
|
||||
paddingRight: 0,
|
||||
paddingLeft: 0,
|
||||
},
|
||||
input: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
}),
|
||||
({ groupBy }) =>
|
||||
groupBy &&
|
||||
styledCss({
|
||||
[`>div:nth-of-type(${groupBy}n):not(div:last-child)`]: {
|
||||
marginRight: 4,
|
||||
},
|
||||
})
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user