Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/short-rings-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@alfalab/core-components-tag': minor
'@alfalab/core-components': minor
---

##### Tag

- Добавлен компонент `IndicatorTag` для тега с числовым индикатором
1 change: 1 addition & 0 deletions packages/tag/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"main": "index.js",
"module": "./esm/index.js",
"dependencies": {
"@alfalab/core-components-indicator": "^4.0.2",
"@alfalab/core-components-mq": "^6.0.3",
"@alfalab/hooks": "^1.13.1",
"classnames": "^2.5.1",
Expand Down
6 changes: 3 additions & 3 deletions packages/tag/src/Component.responsive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import React, { forwardRef } from 'react';

import { useIsDesktop } from '@alfalab/core-components-mq';

import { type BaseTagProps } from './components/base-tag';
import { TagDesktop } from './desktop';
import { TagMobile } from './mobile';
import { type BaseTagProps } from './typings';

export type TagProps = Omit<BaseTagProps, 'styles' | 'colorStylesMap'> & {
export interface TagProps extends Omit<BaseTagProps, 'styles' | 'colorStylesMap'> {
/**
* Контрольная точка, с нее начинается desktop версия
* @default 1024
Expand All @@ -23,7 +23,7 @@ export type TagProps = Omit<BaseTagProps, 'styles' | 'colorStylesMap'> & {
* @deprecated Используйте client
*/
defaultMatchMediaValue?: boolean | (() => boolean);
};
}

export const Tag = forwardRef<HTMLButtonElement, TagProps>(
(
Expand Down
73 changes: 66 additions & 7 deletions packages/tag/src/Component.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';

import { IndicatorTag } from './components/indicator-tag';
import { TagDesktop as Tag } from './desktop';
import { resolveGeometry } from './utils';

describe('Snapshots tests', () => {
it('should match snapshot', () => {
Expand Down Expand Up @@ -84,13 +86,13 @@ describe('Attributes tests', () => {
});

describe('Render tests', () => {
test('should unmount without errors', () => {
it('should unmount without errors', () => {
const { unmount } = render(<Tag rightAddons={<div>addons</div>}>Tag</Tag>);

expect(unmount).not.toThrow();
});

test('should contain right addons', () => {
it('should contain right addons', () => {
const rightAddonText = 'Right addon text';

const { container, getByText } = render(
Expand All @@ -100,7 +102,7 @@ describe('Render tests', () => {
expect(container.firstElementChild).toContainElement(getByText(rightAddonText));
});

test('should contain left addons', () => {
it('should contain left addons', () => {
const leftAddonText = 'Left addon text';

const { container, getByText } = render(
Expand All @@ -109,10 +111,67 @@ describe('Render tests', () => {

expect(container.firstElementChild).toContainElement(getByText(leftAddonText));
});

it.each([32, 40] as const)(
'should keep rectangular dot indicator inside tag bounds for size=%s',
(size) => {
const { container } = render(
<IndicatorTag
size={size}
shape='rectangular'
indicatorProps={{ mode: 'dot' }}
leftAddons={<span />}
/>,
);
const indicator = container.querySelector('[class*="indicator"]');

expect(indicator).toHaveStyle({ left: '36px', top: '4px' });
},
);

it.each([32, 40] as const)(
'should place rounded dot indicator by circle diagonal for size=%s',
(size) => {
const { container } = render(
<IndicatorTag
size={size}
shape='rounded'
indicatorProps={{ mode: 'dot' }}
leftAddons={<span />}
/>,
);
const indicator = container.querySelector('[class*="indicator"]') as HTMLElement;

expect(parseFloat(indicator.style.left)).toBeLessThanOrEqual(36);
expect(parseFloat(indicator.style.top)).toBeGreaterThanOrEqual(4);
},
);

it('should use separate cutout gap for count indicator', () => {
const dotGeometry = resolveGeometry({
width: 40,
height: 32,
shape: 'rectangular',
indicatorProps: { mode: 'dot' },
});
const countGeometry = resolveGeometry({
width: 40,
height: 32,
shape: 'rectangular',
indicatorProps: { mode: 'count', value: 7 },
});

expect(dotGeometry.cutoutR).toBe(6);
expect(countGeometry.cutoutR).toBe(11);
expect(dotGeometry.junctionR).toBe(4);
expect(dotGeometry.junctionRect).toBe(6);
expect(countGeometry.junctionR).toBe(2);
expect(countGeometry.junctionRect).toBe(4);
});
});

describe('Interaction tests', () => {
test('should call `onClick` prop, if tag not disabled', () => {
describe('Interaction its', () => {
it('should call `onClick` prop, if tag not disabled', () => {
const cb = jest.fn();

const { container } = render(<Tag onClick={cb}>Press me!</Tag>);
Expand All @@ -124,7 +183,7 @@ describe('Interaction tests', () => {
expect(cb).toHaveBeenCalledTimes(1);
});

test('should not call `onClick` prop, if tag is disabled', () => {
it('should not call `onClick` prop, if tag is disabled', () => {
const cb = jest.fn();

const { container } = render(
Expand All @@ -140,7 +199,7 @@ describe('Interaction tests', () => {
expect(cb).toHaveBeenCalledTimes(0);
});

test('should not call `onClick` prop, if tag is disabled and checked', () => {
it('should not call `onClick` prop, if tag is disabled and checked', () => {
const cb = jest.fn();

const { container } = render(
Expand Down
189 changes: 17 additions & 172 deletions packages/tag/src/components/base-tag/Component.tsx
Original file line number Diff line number Diff line change
@@ -1,155 +1,30 @@
import React, {
type ButtonHTMLAttributes,
forwardRef,
type ReactNode,
type RefObject,
useRef,
} from 'react';
import React, { forwardRef, useRef } from 'react';
import mergeRefs from 'react-merge-refs';
import cn from 'classnames';

import { useFocus } from '@alfalab/hooks';

import defaultColors from './default.module.css';
import commonStyles from './index.module.css';
import invertedColors from './inverted.module.css';

const colorCommonStyles = {
default: defaultColors,
inverted: invertedColors,
} as const;

export type StyleColors = {
default: {
[key: string]: string;
};
inverted: {
[key: string]: string;
};
};

export type NativeProps = ButtonHTMLAttributes<HTMLButtonElement>;

export type BaseTagProps = Omit<NativeProps, 'onClick'> & {
/**
* Отображение кнопки в отмеченном (зажатом) состоянии
*/
checked?: boolean;

/**
* Размер компонента
*/
size?: 32 | 40 | 48 | 56 | 64 | 72;

/**
* Дочерние элементы.
*/
children?: ReactNode;

/**
* Дополнительный класс для обёртки children
*/
childrenClassName?: string;

/**
* Слот слева
*/
leftAddons?: ReactNode;

/**
* Слот справа
*/
rightAddons?: ReactNode;

/**
* Идентификатор для систем автоматизированного тестирования
*/
dataTestId?: string;

/**
* Обработчик нажатия
*/
onClick?: (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
payload: {
checked: boolean;
name?: string;
},
) => void;

/**
* ref на children
*/

childrenRef?: RefObject<HTMLSpanElement>;

/**
* Набор цветов для компонента
*/
colors?: 'default' | 'inverted';

/**
* @deprecated данный проп больше не используется, временно оставлен для обратной совместимости
* Используйте props shape и view
* Вариант тега
*/
variant?: 'default' | 'alt';

/**
* Форма тега
*/
shape?: 'rounded' | 'rectangular';

/**
* Стиль тега
* @default outlined
*/
view?: 'outlined' | 'filled' | 'transparent';

/**
* Включает размытие фона для некоторых вариантов тега
* @description Может привести к просадке fps и другим багам. Старайтесь не размещать слишком много заблюреных элементов на одной странице.
*/
allowBackdropBlur?: boolean;

/**
* Основные стили компонента.
*/
styles?: { [key: string]: string };

/**
* Стили компонента для default и inverted режима.
*/
colorStylesMap?: StyleColors;
};
import { type BaseTagProps } from '../../typings';
import { NativeTag as DefaultTag } from '../native-tag';

export const BaseTag = forwardRef<HTMLButtonElement, BaseTagProps>(
(
{
allowBackdropBlur,
rightAddons,
leftAddons,
Component = DefaultTag,
children,
size = 48,
checked,
className,
dataTestId,
name,
colors = 'default',
onClick,
variant = 'default',
shape,
view = 'outlined',
childrenClassName,
childrenRef,
shape,
onClick,
styles = {},
colorStylesMap = { default: {}, inverted: {} },
...restProps
},
ref,
) => {
const colorStyles = colorStylesMap[colors];

const tagRef = useRef<HTMLButtonElement>(null);

const [focused] = useFocus(tagRef, 'keyboard');
Expand All @@ -158,58 +33,28 @@ export const BaseTag = forwardRef<HTMLButtonElement, BaseTagProps>(

const shapeClassName = shape || variantClassName;

const sizeClassName = `size-${size}`;

const tagProps = {
className: cn(
commonStyles.component,
colorCommonStyles[colors].component,
colorStyles.component,
commonStyles[sizeClassName],
styles[sizeClassName],
colorCommonStyles[colors][view],
commonStyles[view],
{
[commonStyles.allowBackdropBlur]: allowBackdropBlur,
[commonStyles.checked]: checked,
[commonStyles[shapeClassName]]: Boolean(commonStyles[shapeClassName]),
[styles[shapeClassName]]: Boolean(styles[shapeClassName]),
[colorCommonStyles[colors].checked]: checked,
[colorStyles[view]]: Boolean(colorStyles[view]),
[commonStyles.focused]: focused,
[commonStyles.withRightAddons]: Boolean(rightAddons),
[commonStyles.withLeftAddons]: Boolean(leftAddons),
[commonStyles.noContent]: Boolean((leftAddons || rightAddons) && !children),
},
className,
),
'data-test-id': dataTestId,
};

const handleClick = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
if (onClick) {
onClick(event, { name, checked: !checked });
}
};

return (
<button
<Component
ref={mergeRefs([tagRef, ref])}
type='button'
checked={checked}
colors={colors}
view={view}
colorStyles={colorStylesMap[colors]}
shape={shapeClassName}
size={size}
styles={styles}
focused={focused}
onClick={handleClick}
{...tagProps}
{...restProps}
>
{leftAddons ? <span className={commonStyles.addons}>{leftAddons}</span> : null}

{children && (
<span ref={childrenRef} className={childrenClassName}>
{children}
</span>
)}

{rightAddons ? <span className={commonStyles.addons}>{rightAddons}</span> : null}
</button>
{children}
</Component>
);
},
);
Loading