task: форк fractal-ui

This commit is contained in:
Кудряшов Максим
2025-07-17 10:14:43 +04:00
parent 6b0f40508a
commit 02d5e4ee76
1538 changed files with 130335 additions and 6 deletions
+1175 -6
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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>&nbsp;</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>&nbsp;</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());
});
});
@@ -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',
}
@@ -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;
};
@@ -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" />
</>
);
@@ -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