From 0edb5251575b7cecc7bdb3f0e0755c485abdd19d Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Mon, 23 Mar 2026 02:53:18 +0300 Subject: [PATCH 01/11] =?UTF-8?q?feat(tag):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=20Indica?= =?UTF-8?q?torTag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tag/package.json | 1 + packages/tag/src/Component.responsive.tsx | 4 +- .../tag/src/components/base-tag/Component.tsx | 189 ++-------------- .../tag/src/components/button/Component.tsx | 9 + packages/tag/src/components/button/index.ts | 1 + .../components/indicator-tag/Component.tsx | 181 +++++++++++++++ .../indicator-tag/default.module.css | 44 ++++ .../components/indicator-tag/index.module.css | 36 +++ .../tag/src/components/indicator-tag/index.ts | 1 + .../indicator-tag/inverted.module.css | 44 ++++ .../src/components/native-tag/Component.tsx | 93 ++++++++ .../default.module.css | 0 .../{base-tag => native-tag}/index.module.css | 0 .../tag/src/components/native-tag/index.ts | 1 + .../inverted.module.css | 0 .../tag/src/desktop/Component.desktop.tsx | 3 +- packages/tag/src/index.ts | 3 + packages/tag/src/mobile/Component.mobile.tsx | 3 +- packages/tag/src/typings.ts | 124 +++++++++++ packages/tag/src/utils.ts | 208 ++++++++++++++++++ packages/tag/src/vars.css | 16 ++ packages/tag/tsconfig.build.json | 4 +- packages/tag/tsconfig.json | 3 + yarn.lock | 1 + 24 files changed, 792 insertions(+), 177 deletions(-) create mode 100644 packages/tag/src/components/button/Component.tsx create mode 100644 packages/tag/src/components/button/index.ts create mode 100644 packages/tag/src/components/indicator-tag/Component.tsx create mode 100644 packages/tag/src/components/indicator-tag/default.module.css create mode 100644 packages/tag/src/components/indicator-tag/index.module.css create mode 100644 packages/tag/src/components/indicator-tag/index.ts create mode 100644 packages/tag/src/components/indicator-tag/inverted.module.css create mode 100644 packages/tag/src/components/native-tag/Component.tsx rename packages/tag/src/components/{base-tag => native-tag}/default.module.css (100%) rename packages/tag/src/components/{base-tag => native-tag}/index.module.css (100%) create mode 100644 packages/tag/src/components/native-tag/index.ts rename packages/tag/src/components/{base-tag => native-tag}/inverted.module.css (100%) create mode 100644 packages/tag/src/typings.ts create mode 100644 packages/tag/src/utils.ts diff --git a/packages/tag/package.json b/packages/tag/package.json index 4384db15aa..b84c9a4ea8 100644 --- a/packages/tag/package.json +++ b/packages/tag/package.json @@ -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", diff --git a/packages/tag/src/Component.responsive.tsx b/packages/tag/src/Component.responsive.tsx index 890aaebb29..9bfdec0016 100644 --- a/packages/tag/src/Component.responsive.tsx +++ b/packages/tag/src/Component.responsive.tsx @@ -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 & { +export interface TagProps extends Omit { /** * Контрольная точка, с нее начинается desktop версия * @default 1024 diff --git a/packages/tag/src/components/base-tag/Component.tsx b/packages/tag/src/components/base-tag/Component.tsx index 47a04d81ac..255a32b4c4 100644 --- a/packages/tag/src/components/base-tag/Component.tsx +++ b/packages/tag/src/components/base-tag/Component.tsx @@ -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; - -export type BaseTagProps = Omit & { - /** - * Отображение кнопки в отмеченном (зажатом) состоянии - */ - checked?: boolean; - - /** - * Размер компонента - */ - size?: 32 | 40 | 48 | 56 | 64 | 72; - - /** - * Дочерние элементы. - */ - children?: ReactNode; - - /** - * Дополнительный класс для обёртки children - */ - childrenClassName?: string; - - /** - * Слот слева - */ - leftAddons?: ReactNode; - - /** - * Слот справа - */ - rightAddons?: ReactNode; - - /** - * Идентификатор для систем автоматизированного тестирования - */ - dataTestId?: string; - - /** - * Обработчик нажатия - */ - onClick?: ( - event: React.MouseEvent, - payload: { - checked: boolean; - name?: string; - }, - ) => void; - - /** - * ref на children - */ - - childrenRef?: RefObject; - - /** - * Набор цветов для компонента - */ - 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( ( { - 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(null); const [focused] = useFocus(tagRef, 'keyboard'); @@ -158,34 +33,6 @@ export const BaseTag = forwardRef( 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) => { if (onClick) { onClick(event, { name, checked: !checked }); @@ -193,23 +40,21 @@ export const BaseTag = forwardRef( }; return ( - + {children} + ); }, ); diff --git a/packages/tag/src/components/button/Component.tsx b/packages/tag/src/components/button/Component.tsx new file mode 100644 index 0000000000..97a8298532 --- /dev/null +++ b/packages/tag/src/components/button/Component.tsx @@ -0,0 +1,9 @@ +import React, { type ButtonHTMLAttributes, forwardRef } from 'react'; + +export const Button = forwardRef>( + ({ children, onClick, ...rest }, ref) => ( + + ), +); diff --git a/packages/tag/src/components/button/index.ts b/packages/tag/src/components/button/index.ts new file mode 100644 index 0000000000..e51a5d2440 --- /dev/null +++ b/packages/tag/src/components/button/index.ts @@ -0,0 +1 @@ +export * from './Component'; diff --git a/packages/tag/src/components/indicator-tag/Component.tsx b/packages/tag/src/components/indicator-tag/Component.tsx new file mode 100644 index 0000000000..25ebf4b145 --- /dev/null +++ b/packages/tag/src/components/indicator-tag/Component.tsx @@ -0,0 +1,181 @@ +import React, { forwardRef, Fragment, type MouseEventHandler, useId } from 'react'; +import cn from 'classnames'; + +import { Indicator } from '@alfalab/core-components-indicator'; + +import { type BaseTagProps } from '../../typings'; +import { + JR, + JR_RECT, + resolveGeometry, + resolveSizeToDimensions, + resolveValueToIndicatorShiftX, +} from '../../utils'; +import { Button as BaseButton } from '../button'; + +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 interface IndicatorTagProps + extends Omit< + BaseTagProps, + | 'Component' + | 'colorStylesMap' + | 'onClick' + | 'styles' + | 'view' + | 'variant' + | 'allowBackdropBlur' + | 'childrenRef' + | 'size' + > { + size: 32 | 40; + onClick?: MouseEventHandler; + focused?: boolean; +} + +export const IndicatorTag = forwardRef( + ( + { + size = 40, + indicatorProps, + colors = 'default', + className, + childrenClassName, + shape, + checked, + children, + leftAddons, + rightAddons, + dataTestId, + onClick, + focused = false, + ...restProps + }, + ref, + ) => { + const maskId = useId(); + + const isRect = shape === 'rectangular'; + const hasIndicator = indicatorProps !== undefined; + + const value = indicatorProps?.value; + const isDot = hasIndicator && value === undefined; + + const colorStyle = colorCommonStyles?.[colors]; + + const { width, height } = resolveSizeToDimensions(size); + + const { badgeX, badgeY, cutoutR, cr, junctions } = resolveGeometry({ + width, + height, + shape, + indicatorProps, + }); + + const buttonProps = { + className: cn(commonStyles.badgeIcon, colorStyle.badgeIcon, className, { + [commonStyles.focused]: focused, + [colorStyle.checked]: Boolean(checked), + }), + 'data-test-id': dataTestId, + style: { width, minWidth: width, height }, + }; + + return ( + + + + + {isRect ? ( + + ) : ( + + )} + + {hasIndicator ? ( + + + {junctions ? ( + + + + + + + ) : null} + + ) : null} + + + + +
+ {leftAddons ?? rightAddons ?? children} +
+
+
+ + {hasIndicator ? ( + + ) : null} +
+ ); + }, +); diff --git a/packages/tag/src/components/indicator-tag/default.module.css b/packages/tag/src/components/indicator-tag/default.module.css new file mode 100644 index 0000000000..13ca8d24f3 --- /dev/null +++ b/packages/tag/src/components/indicator-tag/default.module.css @@ -0,0 +1,44 @@ +@import '../../vars.css'; + +.mask { + fill: var(--tag-badge-icon-mask-positive-color); +} + +.shapeInner { + background: var(--tag-badge-icon-background-color); + color: inherit; + + & svg { + fill: var(--color-light-text-primary); + } + + &:active:not(:disabled) svg { + fill: var(--color-light-text-primary-inverted); + } +} + +.badgeIcon { + color: var(--tag-text-color); + + &:active:not(:disabled) .shapeInner { + background: var(--tag-badge-icon-background-color-active); + } + + &.checked .shapeInner { + background: var(--tag-background-color-checked); + + & svg { + fill: var(--tag-text-color-checked); + } + } + + @media (hover: hover) { + &:hover .shapeInner { + background: var(--tag-badge-icon-background-color-hover); + } + + &.checked:hover:not(:disabled) .shapeInner { + background: var(--tag-background-color-checked-hover); + } + } +} diff --git a/packages/tag/src/components/indicator-tag/index.module.css b/packages/tag/src/components/indicator-tag/index.module.css new file mode 100644 index 0000000000..3e1f230509 --- /dev/null +++ b/packages/tag/src/components/indicator-tag/index.module.css @@ -0,0 +1,36 @@ +@import '../../vars.css'; + +.svg { + pointer-events: none; +} + +.indicator { + position: absolute; + transform: translate(-50%, -50%); + pointer-events: none; + z-index: 1; +} + +.shapeInner { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + user-select: none; + pointer-events: none; +} + +.badgeIcon { + position: relative; + cursor: pointer; + margin: 0; + padding: 0; + border: 0; + background: none; +} + +.focused { + @mixin focus-outline; +} diff --git a/packages/tag/src/components/indicator-tag/index.ts b/packages/tag/src/components/indicator-tag/index.ts new file mode 100644 index 0000000000..e51a5d2440 --- /dev/null +++ b/packages/tag/src/components/indicator-tag/index.ts @@ -0,0 +1 @@ +export * from './Component'; diff --git a/packages/tag/src/components/indicator-tag/inverted.module.css b/packages/tag/src/components/indicator-tag/inverted.module.css new file mode 100644 index 0000000000..410c5fd8d2 --- /dev/null +++ b/packages/tag/src/components/indicator-tag/inverted.module.css @@ -0,0 +1,44 @@ +@import '../../vars.css'; + +.mask { + fill: var(--tag-badge-icon-mask-positive-color-inverted); +} + +.shapeInner { + background: var(--tag-badge-icon-inverted-background-color); + color: inherit; + + & svg { + fill: var(--color-light-text-primary-inverted); + } + + &:active:not(:disabled) svg { + fill: var(--color-light-text-primary-inverted); + } +} + +.badgeIcon { + color: var(--tag-inverted-text-color); + + &:active:not(:disabled) .shapeInner { + background: var(--tag-badge-icon-inverted-background-color-active); + } + + &.checked .shapeInner { + background: var(--tag-inverted-background-color-checked); + + & svg { + fill: var(--tag-inverted-text-color-checked); + } + } + + @media (hover: hover) { + &:hover .shapeInner { + background: var(--tag-badge-icon-inverted-background-color-hover); + } + + &.checked:hover:not(:disabled) .shapeInner { + background: var(--tag-inverted-background-color-checked-hover); + } + } +} diff --git a/packages/tag/src/components/native-tag/Component.tsx b/packages/tag/src/components/native-tag/Component.tsx new file mode 100644 index 0000000000..86c9b2d6fe --- /dev/null +++ b/packages/tag/src/components/native-tag/Component.tsx @@ -0,0 +1,93 @@ +import React, { forwardRef, type MouseEventHandler } from 'react'; +import cn from 'classnames'; + +import { type BaseTagProps, type StyleColors } from '../../typings'; +import { Button as BaseButton } from '../button'; + +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; + +interface NativeTagProps + extends Omit { + shape: 'rounded' | 'rectangular'; + colorStyles: StyleColors['default']; + onClick?: MouseEventHandler; + focused?: boolean; +} + +export const NativeTag = forwardRef( + ( + { + allowBackdropBlur, + rightAddons, + leftAddons, + indicatorProps, + children, + size, + checked, + className, + dataTestId, + colors = 'default', + onClick, + colorStyles, + childrenClassName, + childrenRef, + disabled, + shape, + styles = {}, + view = 'outlined', + focused = false, + ...restProps + }, + ref, + ) => { + const sizeClassName = `size-${size}`; + + const buttonProps = { + 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[shape]]: Boolean(commonStyles[shape]), + [styles[shape]]: Boolean(styles[shape]), + [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, + disabled, + }; + + return ( + + {leftAddons ? {leftAddons} : null} + + {children ? ( + + {children} + + ) : null} + + {rightAddons ? {rightAddons} : null} + + ); + }, +); diff --git a/packages/tag/src/components/base-tag/default.module.css b/packages/tag/src/components/native-tag/default.module.css similarity index 100% rename from packages/tag/src/components/base-tag/default.module.css rename to packages/tag/src/components/native-tag/default.module.css diff --git a/packages/tag/src/components/base-tag/index.module.css b/packages/tag/src/components/native-tag/index.module.css similarity index 100% rename from packages/tag/src/components/base-tag/index.module.css rename to packages/tag/src/components/native-tag/index.module.css diff --git a/packages/tag/src/components/native-tag/index.ts b/packages/tag/src/components/native-tag/index.ts new file mode 100644 index 0000000000..e51a5d2440 --- /dev/null +++ b/packages/tag/src/components/native-tag/index.ts @@ -0,0 +1 @@ +export * from './Component'; diff --git a/packages/tag/src/components/base-tag/inverted.module.css b/packages/tag/src/components/native-tag/inverted.module.css similarity index 100% rename from packages/tag/src/components/base-tag/inverted.module.css rename to packages/tag/src/components/native-tag/inverted.module.css diff --git a/packages/tag/src/desktop/Component.desktop.tsx b/packages/tag/src/desktop/Component.desktop.tsx index 0f252676d5..44d0d45d3e 100644 --- a/packages/tag/src/desktop/Component.desktop.tsx +++ b/packages/tag/src/desktop/Component.desktop.tsx @@ -1,6 +1,7 @@ import React, { forwardRef } from 'react'; -import { BaseTag, type BaseTagProps } from '../components/base-tag'; +import { BaseTag } from '../components/base-tag'; +import { type BaseTagProps } from '../typings'; import defaultColors from './default.desktop.module.css'; import styles from './desktop.module.css'; diff --git a/packages/tag/src/index.ts b/packages/tag/src/index.ts index 5d8ef83014..c0d8dab08b 100644 --- a/packages/tag/src/index.ts +++ b/packages/tag/src/index.ts @@ -1,2 +1,5 @@ export { Tag } from './Component.responsive'; export type { TagProps } from './Component.responsive'; + +export { IndicatorTag } from './components/indicator-tag'; +export type { IndicatorTagProps } from './components/indicator-tag'; diff --git a/packages/tag/src/mobile/Component.mobile.tsx b/packages/tag/src/mobile/Component.mobile.tsx index c21271a058..b187fec9e9 100644 --- a/packages/tag/src/mobile/Component.mobile.tsx +++ b/packages/tag/src/mobile/Component.mobile.tsx @@ -1,6 +1,7 @@ import React, { forwardRef } from 'react'; -import { BaseTag, type BaseTagProps } from '../components/base-tag'; +import { BaseTag } from '../components/base-tag'; +import { type BaseTagProps } from '../typings'; import defaultColors from './default.mobile.module.css'; import invertedColors from './inverted.mobile.module.css'; diff --git a/packages/tag/src/typings.ts b/packages/tag/src/typings.ts new file mode 100644 index 0000000000..b4923e3e04 --- /dev/null +++ b/packages/tag/src/typings.ts @@ -0,0 +1,124 @@ +import { + type ButtonHTMLAttributes, + type ComponentType, + type ReactNode, + type RefObject, +} from 'react'; + +export type StyleColors = { + default: { + [key: string]: string; + }; + inverted: { + [key: string]: string; + }; +}; + +export type NativeProps = ButtonHTMLAttributes; + +export interface BaseTagProps extends Omit { + /** + * Отображение кнопки в отмеченном (зажатом) состоянии + */ + checked?: boolean; + + /** + * Размер компонента + */ + size?: 32 | 40 | 48 | 56 | 64 | 72; + + /** + * Компонент для рендера разметки тега (по умолчанию — внутренний Tag). + */ + Component?: ComponentType; + + /** + * Дочерние элементы. + * @description Отображение зависит от переданного `Component` (например, вариант с `IndicatorTag` может не показывать текст, если так заложено в компоненте). + */ + children?: ReactNode; + + /** + * Дополнительный класс для обёртки children + */ + childrenClassName?: string; + + /** + * Слот слева + * @description При кастомном `Component` (например, `IndicatorTag`) прокидывается в его разметку. + */ + leftAddons?: ReactNode; + + /** + * Слот справа + */ + rightAddons?: ReactNode; + + /** + * Идентификатор для систем автоматизированного тестирования + */ + dataTestId?: string; + + /** + * Обработчик нажатия + */ + onClick?: ( + event: React.MouseEvent, + payload: { + checked: boolean; + name?: string; + }, + ) => void; + + /** + * ref на children + */ + childrenRef?: RefObject; + + /** + * Набор цветов для компонента + */ + colors?: 'default' | 'inverted'; + + /** + * @deprecated данный проп больше не используется, временно оставлен для обратной совместимости + * Используйте props shape и view + * Вариант тега + */ + variant?: 'default' | 'alt'; + + /** + * Форма тега + */ + shape?: 'rounded' | 'rectangular'; + + /** + * Стиль тега + * @default outlined + */ + view?: 'outlined' | 'filled' | 'transparent'; + + /** + * Включает размытие фона для некоторых вариантов тега + * @description Может привести к просадке fps и другим багам. Старайтесь не размещать слишком много заблюреных элементов на одной странице. + */ + allowBackdropBlur?: boolean; + + /** + * Свойства индикатора (бейдж с числом или точкой). + * @description Режим с индикатором задаётся пропом `Component` (например, `IndicatorTag`), а не наличием `indicatorProps`. + */ + indicatorProps?: { + value?: number; + }; + + /** + * Основные стили компонента. + */ + styles?: { [key: string]: string }; + + /** + * Стили компонента для default и inverted режима. + */ + colorStylesMap?: StyleColors; +}; diff --git a/packages/tag/src/utils.ts b/packages/tag/src/utils.ts new file mode 100644 index 0000000000..828c2c0c90 --- /dev/null +++ b/packages/tag/src/utils.ts @@ -0,0 +1,208 @@ +import { type BaseTagProps } from './typings'; + +type JunctionPoint = { cx: number; cy: number }; + +export const DOT_RADIUS = 5; +export const BADGE_NUMBER_RADIUS = 8; + +const OFFSET = 3; +const SIN_45 = Math.SQRT2 / 2; + +/** Радиус круга в точках пересечения эллипса и окружности */ +export const JR = 2; +/** Сторона чёрного квадрата-резца в точках пересечения эллипса и окружности */ +export const JR_RECT = 4; + +const EPSILON = 1e-3; +const DOUBLE_INDICATOR_SHIFT_X = 3; +const TRIPLE_INDICATOR_SHIFT_X = 6; + +type BadgeIconSize = NonNullable; + +type BadgeIconDimensions = { + width: number; + height: number; +} + +/** Преобразует пресет-size в значения width/height */ +export const resolveSizeToDimensions = (size: BadgeIconSize): BadgeIconDimensions => { + switch (size) { + case 32: + return { width: 40, height: 32 }; + default: + return { width: size, height: size }; + } +}; + +/** Сдвиг индикатора по X от числа цифр */ +export const resolveValueToIndicatorShiftX = (value: number | undefined): number => { + if (value == null || value < 10) return 0; + + return value >= 100 ? TRIPLE_INDICATOR_SHIFT_X : DOUBLE_INDICATOR_SHIFT_X; +}; + +type IntersectEllipseCircleParams = { + ecx: number; + ecy: number; + erx: number; + ery: number; + ccx: number; + ccy: number; + cr: number; +}; + +/** Находит две точки пересечения эллипса и окружности */ +const intersectEllipseCircle = ({ + ecx, + ecy, + erx, + ery, + ccx, + ccy, + cr, +}: IntersectEllipseCircleParams): [JunctionPoint, JunctionPoint] | null => { + const targetSq = cr * cr; + const STEPS = 360; + const hits: JunctionPoint[] = []; + + const isSamePoint = (a: JunctionPoint, b: JunctionPoint): boolean => + Math.hypot(a.cx - b.cx, a.cy - b.cy) < EPSILON; + + for (let i = 0; i < STEPS; i++) { + const t0 = (i / STEPS) * 2 * Math.PI; + const t1 = ((i + 1) / STEPS) * 2 * Math.PI; + + const distSq = (t: number): number => { + const x = ecx + erx * Math.cos(t); + const y = ecy + ery * Math.sin(t); + + return (x - ccx) ** 2 + (y - ccy) ** 2 - targetSq; + }; + + const d0 = distSq(t0); + const d1 = distSq(t1); + + if (d0 * d1 <= 0) { + const frac = Math.abs(d0) / (Math.abs(d0) + Math.abs(d1) + 1e-12); + const t = t0 + frac * (t1 - t0); + const nextHit: JunctionPoint = { + cx: ecx + erx * Math.cos(t), + cy: ecy + ery * Math.sin(t), + }; + + if (!hits.some((hit) => isSamePoint(hit, nextHit))) { + hits.push(nextHit); + } + } + } + + if (hits.length < 2) { + return null; + } + + const rightPoint = hits.reduce((currentRight, hit) => (hit.cx > currentRight.cx ? hit : currentRight)); + const topPoint = hits.reduce((currentTop, hit) => (hit.cy < currentTop.cy ? hit : currentTop)); + + if (!isSamePoint(rightPoint, topPoint)) { + return [rightPoint, topPoint]; + } + + const fallbackPoint = hits.find((hit) => !isSamePoint(hit, rightPoint)); + + return fallbackPoint ? [rightPoint, fallbackPoint] : null; +}; + +type ComputeGeometryParams = { + width: number; + height: number; + shape: BaseTagProps['shape']; + /** + * Без пропа — маска без выреза под бейдж. + * Объект (в т.ч. `{}`) — режим индикатора: `value === undefined` → точка, иначе число. + */ + indicatorProps?: BaseTagProps['indicatorProps']; +}; + +const resolveBadgeRadiusFromIndicatorProps = ( + indicatorProps: BaseTagProps['indicatorProps'] | undefined, +): number => { + if (indicatorProps === undefined) { + return 0; + } + + return indicatorProps.value === undefined ? DOT_RADIUS : BADGE_NUMBER_RADIUS; +}; + +type BadgeGeometry = { + badgeX: number; + badgeY: number; + cutoutR: number; + cr?: number; + junctions: [JunctionPoint, JunctionPoint] | null; +}; + +/** Расчет геометрии маски бейдж-иконки: положение бейджа, радиус выреза и точки стыка (junction) */ +export const resolveGeometry = ({ width: w, height: h, shape, indicatorProps }: ComputeGeometryParams): BadgeGeometry => { + const badgeRadius = resolveBadgeRadiusFromIndicatorProps(indicatorProps); + + if (badgeRadius === 0) { + return { + badgeX: 0, + badgeY: 0, + cutoutR: 0, + cr: shape === 'rectangular' ? Math.round(Math.min(w, h) * 0.22) : undefined, + junctions: null, + }; + } + + const cutoutR = badgeRadius + 2; + const outerR = cutoutR + JR; + + /* + * Rectangular: центр бейджа сдвигаем в правый верхний угол по диагонали 45° относительно скругления (cr). + * Дальше ищем две точки стыка окружности outerR с "внутренним" прямоугольником маски: + * 1) с правой границей x = w - JR (получаем y через уравнение окружности), + * 2) с верхней границей y = JR (получаем x через уравнение окружности). + * Если подкоренные выражения отрицательные, стыка нет (junctions = null). + */ + if (shape === 'rectangular') { + const cr = Math.round(Math.min(w, h) * 0.22); + const badgeX = w - cr + (cr + OFFSET) * SIN_45; + const badgeY = cr - (cr + OFFSET) * SIN_45; + + const edgeRight = w - JR; + const dx = edgeRight - badgeX; + const dySq = outerR * outerR - dx * dx; + + const edgeTop = JR; + const dy = edgeTop - badgeY; + const dxSq = outerR * outerR - dy * dy; + + const junctions: [JunctionPoint, JunctionPoint] | null = + dySq >= 0 && dxSq >= 0 + ? [ + { cx: edgeRight, cy: badgeY + Math.sqrt(dySq) }, + { cx: badgeX - Math.sqrt(dxSq), cy: edgeTop }, + ] + : null; + + return { badgeX, badgeY, cutoutR, cr, junctions }; + } + + const rx = w / 2; + const ry = h / 2; + const badgeX = rx + (rx + OFFSET) * SIN_45; + const badgeY = ry - (ry + OFFSET) * SIN_45; + + const junctions = intersectEllipseCircle({ + ecx: rx, + ecy: ry, + erx: rx - JR, + ery: ry - JR, + ccx: badgeX, + ccy: badgeY, + cr: outerR, + }); + + return { badgeX, badgeY, cutoutR, junctions }; +}; diff --git a/packages/tag/src/vars.css b/packages/tag/src/vars.css index e22c8d5994..7f7b4fce51 100644 --- a/packages/tag/src/vars.css +++ b/packages/tag/src/vars.css @@ -51,6 +51,12 @@ --tag-background-color-checked-active: var(--color-light-accent-secondary-press); --tag-background-color-checked-disabled: var(--color-light-neutral-translucent-100); + /* background-color indicator-tag (badge icon) */ + --tag-badge-icon-background-color: var(--color-light-neutral-translucent-300); + --tag-badge-icon-background-color-hover: var(--color-light-neutral-translucent-300-hover); + --tag-badge-icon-background-color-active: var(--color-light-neutral-translucent-300-press); + --tag-badge-icon-mask-positive-color: var(--color-static-neutral-0); + /* text color */ --tag-text-color: var(--color-light-text-primary); --tag-text-color-checked: var(--color-light-text-primary-inverted); @@ -113,6 +119,16 @@ --color-light-neutral-translucent-100-inverted ); + /* background-color indicator-tag (badge icon) */ + --tag-badge-icon-inverted-background-color: var(--color-light-neutral-translucent-300-inverted); + --tag-badge-icon-inverted-background-color-hover: var( + --color-light-neutral-translucent-300-inverted-hover + ); + --tag-badge-icon-inverted-background-color-active: var( + --color-light-neutral-translucent-300-inverted-press + ); + --tag-badge-icon-mask-positive-color-inverted: var(--color-static-neutral-0); + /* text color */ --tag-inverted-text-color: var(--color-light-text-primary-inverted); --tag-inverted-text-color-checked: var(--color-light-text-primary); diff --git a/packages/tag/tsconfig.build.json b/packages/tag/tsconfig.build.json index d237737248..4b34ad0c0c 100644 --- a/packages/tag/tsconfig.build.json +++ b/packages/tag/tsconfig.build.json @@ -7,11 +7,13 @@ "rootDir": "src", "outDir": "ts-dist", "paths": { + "@alfalab/core-components-indicator": ["../indicator/src"], + "@alfalab/core-components-indicator/*": ["../indicator/src/*"], "@alfalab/core-components-mq": ["../mq/src"], "@alfalab/core-components-mq/*": ["../mq/src/*"], "@alfalab/core-components-tag": ["./src"], "@alfalab/core-components-tag/*": ["./src/*"] } }, - "references": [{ "path": "../mq/tsconfig.build.json" }] + "references": [{ "path": "../indicator/tsconfig.build.json" }, { "path": "../mq/tsconfig.build.json" }] } diff --git a/packages/tag/tsconfig.json b/packages/tag/tsconfig.json index 7d9b823fe8..b9bee2c6fd 100644 --- a/packages/tag/tsconfig.json +++ b/packages/tag/tsconfig.json @@ -6,6 +6,8 @@ "rootDir": "src", "outDir": "no-dist", "paths": { + "@alfalab/core-components-indicator": ["../indicator/src"], + "@alfalab/core-components-indicator/*": ["../indicator/src/*"], "@alfalab/core-components-mq": ["../mq/src"], "@alfalab/core-components-mq/*": ["../mq/src/*"], "@alfalab/core-components-screenshot-utils": ["../screenshot-utils/src"], @@ -17,6 +19,7 @@ } }, "references": [ + { "path": "../indicator/tsconfig.build.json" }, { "path": "../mq/tsconfig.build.json" }, { "path": "../screenshot-utils/tsconfig.build.json" }, { "path": "../test-utils/tsconfig.build.json" } diff --git a/yarn.lock b/yarn.lock index c1d90930ef..fb99c90148 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2005,6 +2005,7 @@ __metadata: version: 0.0.0-use.local resolution: "@alfalab/core-components-tag@workspace:packages/tag" dependencies: + "@alfalab/core-components-indicator": "npm:^4.0.2" "@alfalab/core-components-mq": "npm:^6.0.3" "@alfalab/hooks": "npm:^1.13.1" classnames: "npm:^2.5.1" From 02363c088ff8495eb1264e436d69791f82ec236d Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Mon, 23 Mar 2026 09:08:46 +0300 Subject: [PATCH 02/11] fix eslint --- packages/tag/src/Component.responsive.tsx | 2 +- packages/tag/src/typings.ts | 2 +- packages/tag/src/utils.ts | 19 +++++++++++++------ packages/tag/tsconfig.build.json | 5 ++++- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/tag/src/Component.responsive.tsx b/packages/tag/src/Component.responsive.tsx index 9bfdec0016..9b700e5869 100644 --- a/packages/tag/src/Component.responsive.tsx +++ b/packages/tag/src/Component.responsive.tsx @@ -23,7 +23,7 @@ export interface TagProps extends Omit boolean); -}; +} export const Tag = forwardRef( ( diff --git a/packages/tag/src/typings.ts b/packages/tag/src/typings.ts index b4923e3e04..e828a3b03a 100644 --- a/packages/tag/src/typings.ts +++ b/packages/tag/src/typings.ts @@ -121,4 +121,4 @@ export interface BaseTagProps extends Omit { * Стили компонента для default и inverted режима. */ colorStylesMap?: StyleColors; -}; +} diff --git a/packages/tag/src/utils.ts b/packages/tag/src/utils.ts index 828c2c0c90..37886a8d62 100644 --- a/packages/tag/src/utils.ts +++ b/packages/tag/src/utils.ts @@ -22,7 +22,7 @@ type BadgeIconSize = NonNullable; type BadgeIconDimensions = { width: number; height: number; -} +}; /** Преобразует пресет-size в значения width/height */ export const resolveSizeToDimensions = (size: BadgeIconSize): BadgeIconDimensions => { @@ -100,7 +100,9 @@ const intersectEllipseCircle = ({ return null; } - const rightPoint = hits.reduce((currentRight, hit) => (hit.cx > currentRight.cx ? hit : currentRight)); + const rightPoint = hits.reduce((currentRight, hit) => + hit.cx > currentRight.cx ? hit : currentRight, + ); const topPoint = hits.reduce((currentTop, hit) => (hit.cy < currentTop.cy ? hit : currentTop)); if (!isSamePoint(rightPoint, topPoint)) { @@ -142,7 +144,12 @@ type BadgeGeometry = { }; /** Расчет геометрии маски бейдж-иконки: положение бейджа, радиус выреза и точки стыка (junction) */ -export const resolveGeometry = ({ width: w, height: h, shape, indicatorProps }: ComputeGeometryParams): BadgeGeometry => { +export const resolveGeometry = ({ + width: w, + height: h, + shape, + indicatorProps, +}: ComputeGeometryParams): BadgeGeometry => { const badgeRadius = resolveBadgeRadiusFromIndicatorProps(indicatorProps); if (badgeRadius === 0) { @@ -181,9 +188,9 @@ export const resolveGeometry = ({ width: w, height: h, shape, indicatorProps }: const junctions: [JunctionPoint, JunctionPoint] | null = dySq >= 0 && dxSq >= 0 ? [ - { cx: edgeRight, cy: badgeY + Math.sqrt(dySq) }, - { cx: badgeX - Math.sqrt(dxSq), cy: edgeTop }, - ] + { cx: edgeRight, cy: badgeY + Math.sqrt(dySq) }, + { cx: badgeX - Math.sqrt(dxSq), cy: edgeTop }, + ] : null; return { badgeX, badgeY, cutoutR, cr, junctions }; diff --git a/packages/tag/tsconfig.build.json b/packages/tag/tsconfig.build.json index 4b34ad0c0c..e8d30560d6 100644 --- a/packages/tag/tsconfig.build.json +++ b/packages/tag/tsconfig.build.json @@ -15,5 +15,8 @@ "@alfalab/core-components-tag/*": ["./src/*"] } }, - "references": [{ "path": "../indicator/tsconfig.build.json" }, { "path": "../mq/tsconfig.build.json" }] + "references": [ + { "path": "../indicator/tsconfig.build.json" }, + { "path": "../mq/tsconfig.build.json" } + ] } From a7209853383cf63a1d95d0eff5de6e921ae25a97 Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Mon, 23 Mar 2026 09:11:27 +0300 Subject: [PATCH 03/11] add description demo --- packages/tag/src/docs/description.mdx | 285 ++++++++++++++++---------- 1 file changed, 175 insertions(+), 110 deletions(-) diff --git a/packages/tag/src/docs/description.mdx b/packages/tag/src/docs/description.mdx index 347277d563..c616afa4ca 100644 --- a/packages/tag/src/docs/description.mdx +++ b/packages/tag/src/docs/description.mdx @@ -19,6 +19,8 @@ render(() => { size={size} checked={checked[size]} onClick={() => setChecked({ ...checked, [size]: !checked[size] })} + view='filled' + shape='rectangular' > Label @@ -37,19 +39,27 @@ render(() => { ```jsx live render(() => { + const [checked, setChecked] = React.useState(false); + const addon = (
); + const defaultProps = { + size: 40, + view: 'filled', + shape: 'rectangular' + } + const amountAddon = (
@@ -60,42 +70,61 @@ render(() => {
- Label + + Label +
- + } {...defaultProps}> Label
- }> + } {...defaultProps}> Label
- + Label
+
+ } + checked={checked} + onClick={(_, { checked: nextChecked }) => setChecked(nextChecked)} + {...defaultProps} + /> +
); }); //MOBILE render(() => { + const [checked, setChecked] = React.useState(false); + const addon = (
); + const defaultProps = { + size: 40, + view: 'filled', + shape: 'rectangular' + } + const amountAddon = (
@@ -105,26 +134,37 @@ render(() => { return ( -
- Label -
- + Label
- }> + } {...defaultProps}> + Label + +
+
+ } {...defaultProps}> Label
- + Label
+
+ } + checked={checked} + onClick={(_, { checked: nextChecked }) => setChecked(nextChecked)} + {...defaultProps} + /> +
); @@ -133,56 +173,80 @@ render(() => { ## Состояния и стили -У тега есть две опции, отвечающие за внешний вид: - -- shape (форма) — овальные или прямоугольные. -- view (стиль) — залитые или бордерные. - -Тег может находиться в активном и неактивном состоянии. -Взаимодействие с тегом может быть ограничено с помощью свойства `disabled`. +У тега есть две опции, отвечающие за внешний вид. ```jsx live render(() => { const [disabled, setDisabled] = React.useState(false); + const [shape, setShape] = React.useState('rectangular'); + const [view, setView] = React.useState('filled'); + const [checkedWithAddon, setCheckedWithAddon] = React.useState(false); + const [checkedPlain, setCheckedPlain] = React.useState(false); - const VIEWS = [ - { key: 'outlinedRounded', view: 'outlined', shape: 'rounded' }, - { key: 'filledRounded', view: 'filled', shape: 'rounded' }, - { key: 'outlinedRectangular', view: 'outlined', shape: 'rectangular' }, - { key: 'filledRectangular', view: 'filled', shape: 'rectangular' }, - ]; - - const [checked, setChecked] = React.useState( - Object.fromEntries(VIEWS.map((item) => [item.key, false])), - ); + const tagProps = { + size: 40, + shape, + view, + disabled, + }; return ( - - {VIEWS.map((item) => ( -
- - setChecked({ ...checked, [item.key]: !checked[item.key] }) - } - > - Label - -
- ))} + +
+ } + onClick={() => setCheckedWithAddon((v) => !v)} + > + Label + +
+
+ setCheckedPlain((v) => !v)} + > + Label + +
+ + + setShape(p.value)} + value={shape} + > + + + + + + + setView(p.value)} + value={view} + > + + + + + setDisabled((prevState) => !prevState)} - label='Disabled' + label='Заблокированное состояние' + onChange={() => setDisabled((v) => !v)} />
); @@ -190,74 +254,75 @@ render(() => { //MOBILE render(() => { const [disabled, setDisabled] = React.useState(false); + const [shape, setShape] = React.useState('rectangular'); + const [view, setView] = React.useState('filled'); + const [checkedWithAddon, setCheckedWithAddon] = React.useState(false); + const [checkedPlain, setCheckedPlain] = React.useState(false); - const ROUNDEDS = [ - { key: 'outlinedRounded', view: 'outlined', shape: 'rounded' }, - { key: 'filledRounded', view: 'filled', shape: 'rounded' }, - ]; - - const RECTANGLES = [ - { key: 'outlinedRectangular', view: 'outlined', shape: 'rectangular' }, - { key: 'filledRectangular', view: 'filled', shape: 'rectangular' }, - ]; - const [checkedRounded, setCheckedRounded] = React.useState( - Object.fromEntries(ROUNDEDS.map((item) => [item.key, false])), - ); - const [checkedRectangular, setCheckedRectangular] = React.useState( - Object.fromEntries(ROUNDEDS.map((item) => [item.key, false])), - ); + const tagProps = { + size: 48, + shape, + view, + disabled, + }; return ( - - {ROUNDEDS.map((item) => ( -
- - setCheckedRounded({ - ...checkedRounded, - [item.key]: !checkedRounded[item.key], - }) - } - > - Label - -
- ))} -
- - {RECTANGLES.map((item) => ( -
- - setCheckedRectangular({ - ...checkedRectangular, - [item.key]: !checkedRectangular[item.key], - }) - } - > - Label - -
- ))} + +
+ } + onClick={() => setCheckedWithAddon((v) => !v)} + > + Label + +
+
+ setCheckedPlain((v) => !v)} + > + Label + +
+ + + setShape(p.value)} + value={shape} + > + + + + + + + setView(p.value)} + value={view} + > + + + + + setDisabled((prevState) => !prevState)} - label='Disabled' + label='Заблокированное состояние' + onChange={() => setDisabled((v) => !v)} />
); From 077a0074c57c1eb1851b63434a8772906078c7ae Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Tue, 24 Mar 2026 10:21:19 +0300 Subject: [PATCH 04/11] add description --- .../components/indicator-tag/Component.tsx | 19 +- packages/tag/src/docs/description.mdx | 1046 ++++++++++++++++- packages/tag/src/typings.ts | 20 +- packages/tag/src/utils.ts | 18 +- 4 files changed, 1068 insertions(+), 35 deletions(-) diff --git a/packages/tag/src/components/indicator-tag/Component.tsx b/packages/tag/src/components/indicator-tag/Component.tsx index 25ebf4b145..4ea18afc03 100644 --- a/packages/tag/src/components/indicator-tag/Component.tsx +++ b/packages/tag/src/components/indicator-tag/Component.tsx @@ -65,8 +65,11 @@ export const IndicatorTag = forwardRef( const isRect = shape === 'rectangular'; const hasIndicator = indicatorProps !== undefined; - const value = indicatorProps?.value; - const isDot = hasIndicator && value === undefined; + const { mode: modeProp, value, ...restIndicatorProps } = indicatorProps ?? {}; + const mode = modeProp ?? (typeof value === 'number' ? 'count' : 'dot'); + + const isDotMode = mode === 'dot'; + const dotModeValue = isDotMode ? undefined : value; const colorStyle = colorCommonStyles?.[colors]; @@ -162,17 +165,21 @@ export const IndicatorTag = forwardRef( childrenClassName, )} > - {leftAddons ?? rightAddons ?? children} + {leftAddons}
{hasIndicator ? ( ) : null} diff --git a/packages/tag/src/docs/description.mdx b/packages/tag/src/docs/description.mdx index c616afa4ca..3d40b8629f 100644 --- a/packages/tag/src/docs/description.mdx +++ b/packages/tag/src/docs/description.mdx @@ -57,8 +57,8 @@ render(() => { const defaultProps = { size: 40, view: 'filled', - shape: 'rectangular' - } + shape: 'rectangular', + }; const amountAddon = (
@@ -75,7 +75,10 @@ render(() => {
- } {...defaultProps}> + } + {...defaultProps} + > Label
@@ -122,8 +125,8 @@ render(() => { const defaultProps = { size: 40, view: 'filled', - shape: 'rectangular' - } + shape: 'rectangular', + }; const amountAddon = (
@@ -133,20 +136,30 @@ render(() => { return ( - +
Label
- -
-
- } {...defaultProps}> +
+ } + {...defaultProps} + > Label
-
+
} {...defaultProps}> Label @@ -156,7 +169,7 @@ render(() => { Label
-
+
} @@ -192,7 +205,7 @@ render(() => { return ( - +
{
- + setShape(p.value)} @@ -230,7 +243,7 @@ render(() => { setView(p.value)} @@ -241,6 +254,12 @@ render(() => { + + +
+ +
+ { const [checkedPlain, setCheckedPlain] = React.useState(false); const tagProps = { - size: 48, + size: 40, shape, view, disabled, @@ -329,6 +348,999 @@ render(() => { }); ``` +## Группа фильтров + +Тэг может использоваться в группы фильтров. При необходимости, у тэга с одной иконкой можно отрендерить индикатор, и использовать его как кнопку доступа к другим фильтрам. Такой вид доступен только для [Filled] тэгов 32 и 40 размеров. + +```jsx live mobileHeight={768} +const PERIOD_OPTIONS = [ + { key: 'week', content: 'Неделя' }, + { key: 'month', content: 'Месяц' }, + { key: 'year', content: 'Год' }, +]; + +const ACCOUNT_OPTIONS = [ + { key: 'acc1', content: 'Счёт 1' }, + { key: 'acc2', content: 'Счёт 2' }, +]; + +const DYNAMIC_META = { + added_expenses: 'Добавленные расходы', + to_client: 'Клиенту банка', + between_accounts: 'Между счетами', + templates: 'Шаблоны', + autopay: 'Автоплатежи', +}; + +const emptyFilters = () => ({ + replenishment: false, + writeoff: false, + dynamicKeys: [], + period: null, + accounts: [], +}); + +const countActive = ({ accounts, dynamicKeys, period, replenishment, writeoff }) => { + let n = 0; + if (replenishment) n += 1; + if (writeoff) n += 1; + n += dynamicKeys.length; + if (period) n += 1; + n += accounts.length; + + return n; +}; + +const DESKTOP_TAG_SIZE = 40; + +render(() => { + const TagFieldSelectArrow = ({ open, size }) => { + const ChevronIcon = size === 40 ? ChevronDownSIcon : ChevronDownMIcon; + + return ( + + ); + }; + + const TagField = ({ + open, + disabled, + innerProps, + valueRenderer, + selected, + selectedMultiple, + placeholder, + Arrow, + size = DESKTOP_TAG_SIZE, + }) => { + const value = valueRenderer({ + selected, + selectedMultiple: selectedMultiple || [], + }); + const filled = Boolean(value); + const display = filled ? value : placeholder; + const checked = open || filled; + + return ( +
+ + {display} + +
+ ); + }; + + const [panelOpen, setPanelOpen] = React.useState(false); + const [countIndicator, setCountIndicator] = React.useState(false); + const [applied, setApplied] = React.useState(emptyFilters); + const [draft, setDraft] = React.useState(emptyFilters); + + const openOrTogglePanel = () => { + if (panelOpen) { + setPanelOpen(false); + } else { + setDraft({ ...applied }); + setPanelOpen(true); + } + }; + + const activeCount = countActive(applied); + + const toggleDraftDynamic = (id) => { + setDraft((prev) => { + const has = prev.dynamicKeys.includes(id); + + if (has) { + return { ...prev, dynamicKeys: prev.dynamicKeys.filter((k) => k !== id) }; + } + + return { ...prev, dynamicKeys: [...prev.dynamicKeys, id] }; + }); + }; + + const toggleAppliedDynamic = (id) => { + setApplied((prev) => ({ + ...prev, + dynamicKeys: prev.dynamicKeys.filter((k) => k !== id), + })); + }; + + const periodValueRenderer = ({ selected }) => { + if (!selected) return null; + + return `Период: ${selected.content}`; + }; + + const accountsValueRenderer = ({ selectedMultiple }) => { + if (!selectedMultiple.length) return null; + if (selectedMultiple.length === 1) return `Счета: ${selectedMultiple[0].content}`; + + return `Счета: Выбрано ${selectedMultiple.length}`; + }; + + const selectRowProps = { + allowUnselect: true, + block: false, + size: 40, + Option: BaseOption, + Field: TagField, + Arrow: TagFieldSelectArrow, + optionsListWidth: 'field', + optionsListClassName: 'tag-filter-row-options', + }; + + const filterSectionTitleStyle = { fontSize: 22, lineHeight: '28px' }; + + return ( + + +
+ } + size={DESKTOP_TAG_SIZE} + view='filled' + shape='rectangular' + checked={panelOpen} + indicatorProps={ + activeCount > 0 + ? countIndicator + ? { mode: 'count', value: activeCount } + : { mode: 'dot' } + : undefined + } + onClick={openOrTogglePanel} + /> + {applied.dynamicKeys.map((id) => ( + toggleAppliedDynamic(id)} + > + {DYNAMIC_META[id]} + + ))} + setApplied((p) => ({ ...p, replenishment: !p.replenishment }))} + > + Пополнения + + setApplied((p) => ({ ...p, writeoff: !p.writeoff }))} + > + Списания + + { + setApplied((p) => ({ ...p, period: next ? next.key : null })); + }} + /> + { + setApplied((p) => ({ + ...p, + accounts: selectedMultiple.map((o) => o.key), + })); + }} + /> +
+ + + +
+ +
+ + + + setCountIndicator((v) => !v)} + /> + + setPanelOpen(false)} + size={500} + componentDivProps={{ + style: { + top: 16, + bottom: 16, + maxHeight: 'calc(100vh - 32px)', + boxSizing: 'border-box', + borderRadius: 24, + }, + }} + > + Фильтры} + /> + + { +
+ + Период + + +
+ {PERIOD_OPTIONS.map(({ key, content }) => ( + + setDraft((p) => ({ + ...p, + period: p.period === key ? null : key, + })) + } + > + {content} + + ))} +
+ + + + Движение средств + + +
+ + setDraft((p) => ({ ...p, replenishment: !p.replenishment })) + } + > + Пополнение + + + setDraft((p) => ({ ...p, writeoff: !p.writeoff })) + } + > + Списание + + toggleDraftDynamic('added_expenses')} + > + Добавленные расходы + +
+ + + + Переводы + + +
+ toggleDraftDynamic('to_client')} + > + Клиенту банка + + toggleDraftDynamic('between_accounts')} + > + Между счетами + +
+ + + + Шаблоны и автоплатежи + + +
+ toggleDraftDynamic('templates')} + > + Шаблоны + + toggleDraftDynamic('autopay')} + > + Автоплатежи + +
+ + + + Счета + + + {ACCOUNT_OPTIONS.map(({ key, content }) => ( +
+ + setDraft((p) => { + const has = p.accounts.includes(key); + + return { + ...p, + accounts: has + ? p.accounts.filter((k) => k !== key) + : [...p.accounts, key], + }; + }) + } + /> +
+ ))} +
+ } +
+ +
+ + +
+
+
+
+ ); +}); +//MOBILE +const PERIOD_OPTIONS = [ + { key: 'week', content: 'Неделя' }, + { key: 'month', content: 'Месяц' }, + { key: 'year', content: 'Год' }, +]; + +const ACCOUNT_OPTIONS = [ + { key: 'acc1', content: 'Счёт 1' }, + { key: 'acc2', content: 'Счёт 2' }, +]; + +const DYNAMIC_META = { + added_expenses: 'Добавленные расходы', + to_client: 'Клиенту банка', + between_accounts: 'Между счетами', + templates: 'Шаблоны', + autopay: 'Автоплатежи', +}; + +const emptyFilters = () => ({ + replenishment: false, + writeoff: false, + dynamicKeys: [], + period: null, + accounts: [], +}); + +const countActive = (f) => { + let n = 0; + if (f.replenishment) n += 1; + if (f.writeoff) n += 1; + n += f.dynamicKeys.length; + if (f.period) n += 1; + n += f.accounts.length; + return n; +}; + +const MOBILE_ROW_TAG_SIZE = 32; + +render(() => { + const TagFieldSelectArrow = ({ open, size }) => { + const ChevronIcon = size === 40 ? ChevronDownSIcon : ChevronDownMIcon; + + return ( + + ); + }; + + const TagField = (props) => { + const { + open, + disabled, + innerProps, + valueRenderer, + selected, + selectedMultiple, + placeholder, + Arrow, + size = MOBILE_ROW_TAG_SIZE, + } = props; + const value = valueRenderer({ + selected, + selectedMultiple: selectedMultiple || [], + }); + const filled = Boolean(value); + const display = filled ? value : placeholder; + const checked = open || filled; + + return ( +
+ + {display} + +
+ ); + }; + + const [panelOpen, setPanelOpen] = React.useState(false); + const [countIndicator, setCountIndicator] = React.useState(false); + const [applied, setApplied] = React.useState(emptyFilters); + const [draft, setDraft] = React.useState(emptyFilters); + + const openOrTogglePanel = () => { + if (panelOpen) { + setPanelOpen(false); + } else { + setDraft({ ...applied }); + setPanelOpen(true); + } + }; + + const activeCount = countActive(applied); + + const toggleDraftDynamic = (id) => { + setDraft((prev) => { + const has = prev.dynamicKeys.includes(id); + if (has) { + return { ...prev, dynamicKeys: prev.dynamicKeys.filter((k) => k !== id) }; + } + return { ...prev, dynamicKeys: [...prev.dynamicKeys, id] }; + }); + }; + + const toggleAppliedDynamic = (id) => { + setApplied((prev) => ({ + ...prev, + dynamicKeys: prev.dynamicKeys.filter((k) => k !== id), + })); + }; + + const periodValueRenderer = ({ selected }) => { + if (!selected) return null; + return `Период: ${selected.content}`; + }; + + const accountsValueRenderer = ({ selectedMultiple }) => { + if (!selectedMultiple.length) return null; + if (selectedMultiple.length === 1) return `Счета: ${selectedMultiple[0].content}`; + return `Счета: Выбрано ${selectedMultiple.length}`; + }; + + const selectRowProps = { + allowUnselect: true, + block: false, + size: MOBILE_ROW_TAG_SIZE, + Option: BaseOption, + Field: TagField, + Arrow: TagFieldSelectArrow, + optionsListWidth: 'field', + }; + + const mobileFilterTagsScrollRowStyle = { + display: 'flex', + flexDirection: 'row', + flexWrap: 'nowrap', + gap: 8, + alignItems: 'center', + overflowX: 'auto', + overflowY: 'hidden', + WebkitOverflowScrolling: 'touch', + touchAction: 'pan-x', + paddingTop: 10, + paddingBottom: 4, + boxSizing: 'border-box', + }; + + const mobileFilterSheetTagsRowStyle = { + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + alignItems: 'center', + }; + + return ( + +
+
+ } + size={MOBILE_ROW_TAG_SIZE} + view='filled' + shape='rectangular' + checked={panelOpen} + indicatorProps={ + activeCount > 0 + ? countIndicator + ? { mode: 'count', value: activeCount } + : { mode: 'dot' } + : undefined + } + onClick={openOrTogglePanel} + /> +
+ + {applied.dynamicKeys.map((id) => ( +
+ toggleAppliedDynamic(id)} + > + {DYNAMIC_META[id]} + +
+ ))} + +
+ + setApplied((p) => ({ ...p, replenishment: !p.replenishment })) + } + > + Пополнения + +
+
+ setApplied((p) => ({ ...p, writeoff: !p.writeoff }))} + > + Списания + +
+ +
+ { + setApplied((p) => ({ ...p, period: next ? next.key : null })); + }} + /> +
+
+ { + setApplied((p) => ({ + ...p, + accounts: selectedMultiple.map((o) => o.key), + })); + }} + /> +
+
+ + + +
+ +
+ + + + setCountIndicator((v) => !v)} + /> + + setPanelOpen(false)} + title='Фильтры' + hasCloser={true} + stickyFooter={true} + initialHeight='full' + actionButton={ +
+ setDraft(emptyFilters())} + > + Сбросить + + { + setApplied({ ...draft }); + setPanelOpen(false); + }} + > + Применить + +
+ } + > + <> + + Период + + +
+ {PERIOD_OPTIONS.map((opt) => ( +
+ + setDraft((p) => ({ + ...p, + period: p.period === opt.key ? null : opt.key, + })) + } + > + {opt.content} + +
+ ))} +
+ + + + + Движение средств + + +
+
+ + setDraft((p) => ({ ...p, replenishment: !p.replenishment })) + } + > + Пополнение + +
+
+ setDraft((p) => ({ ...p, writeoff: !p.writeoff }))} + > + Списание + +
+
+ toggleDraftDynamic('added_expenses')} + > + Добавленные расходы + +
+
+ + + + + Переводы + + +
+
+ toggleDraftDynamic('to_client')} + > + Клиенту банка + +
+
+ toggleDraftDynamic('between_accounts')} + > + Между счетами + +
+
+ + + + + Шаблоны и автоплатежи + + +
+
+ toggleDraftDynamic('templates')} + > + Шаблоны + +
+
+ toggleDraftDynamic('autopay')} + > + Автоплатежи + +
+
+ + + + + Счета + + + + {ACCOUNT_OPTIONS.map((opt) => ( +
+ + setDraft((p) => { + const has = p.accounts.includes(opt.key); + return { + ...p, + accounts: has + ? p.accounts.filter((k) => k !== opt.key) + : [...p.accounts, opt.key], + }; + }) + } + /> +
+ ))} + +
+
+ ); +}); +``` + ## Связанные компоненты Служат триггерами для переключения контекста, см. компонент [Tabs](?path=/docs/tabs--docs). diff --git a/packages/tag/src/typings.ts b/packages/tag/src/typings.ts index e828a3b03a..8bf5b348c3 100644 --- a/packages/tag/src/typings.ts +++ b/packages/tag/src/typings.ts @@ -16,6 +16,20 @@ export type StyleColors = { export type NativeProps = ButtonHTMLAttributes; +export interface IndicatorProps { + /** + * Точка (`dot`) или счётчик (`count`). + * Если не передан: при числовом `value` эффективно `count`, иначе `dot`. + */ + mode?: 'dot' | 'count'; + + /** + * Число для бейджа: в UI показывается только при эффективном режиме `count` (в `dot` в `Indicator` не прокидывается). + * Влияет на геометрию выреза маски при `mode: 'count'` (см. `resolveGeometry`). + */ + value?: number; +} + export interface BaseTagProps extends Omit { /** * Отображение кнопки в отмеченном (зажатом) состоянии @@ -106,11 +120,9 @@ export interface BaseTagProps extends Omit { /** * Свойства индикатора (бейдж с числом или точкой). - * @description Режим с индикатором задаётся пропом `Component` (например, `IndicatorTag`), а не наличием `indicatorProps`. + * @description Режим с индикатором задаётся пропом `Component` (например, `IndicatorTag`). */ - indicatorProps?: { - value?: number; - }; + indicatorProps?: IndicatorProps; /** * Основные стили компонента. diff --git a/packages/tag/src/utils.ts b/packages/tag/src/utils.ts index 37886a8d62..8fbbe93711 100644 --- a/packages/tag/src/utils.ts +++ b/packages/tag/src/utils.ts @@ -118,10 +118,6 @@ type ComputeGeometryParams = { width: number; height: number; shape: BaseTagProps['shape']; - /** - * Без пропа — маска без выреза под бейдж. - * Объект (в т.ч. `{}`) — режим индикатора: `value === undefined` → точка, иначе число. - */ indicatorProps?: BaseTagProps['indicatorProps']; }; @@ -132,7 +128,13 @@ const resolveBadgeRadiusFromIndicatorProps = ( return 0; } - return indicatorProps.value === undefined ? DOT_RADIUS : BADGE_NUMBER_RADIUS; + const mode = indicatorProps.mode ?? (typeof indicatorProps.value === 'number' ? 'count' : 'dot'); + + if (mode === 'dot') { + return DOT_RADIUS; + } + + return typeof indicatorProps.value === 'number' ? BADGE_NUMBER_RADIUS : DOT_RADIUS; }; type BadgeGeometry = { @@ -188,9 +190,9 @@ export const resolveGeometry = ({ const junctions: [JunctionPoint, JunctionPoint] | null = dySq >= 0 && dxSq >= 0 ? [ - { cx: edgeRight, cy: badgeY + Math.sqrt(dySq) }, - { cx: badgeX - Math.sqrt(dxSq), cy: edgeTop }, - ] + { cx: edgeRight, cy: badgeY + Math.sqrt(dySq) }, + { cx: badgeX - Math.sqrt(dxSq), cy: edgeTop }, + ] : null; return { badgeX, badgeY, cutoutR, cr, junctions }; From d68879c01d9c32837de61af2e75d64c5dd8d00bb Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Tue, 24 Mar 2026 11:03:12 +0300 Subject: [PATCH 05/11] add changeset --- .changeset/short-rings-burn.md | 8 ++++++++ packages/tag/src/utils.ts | 9 +++++---- 2 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 .changeset/short-rings-burn.md diff --git a/.changeset/short-rings-burn.md b/.changeset/short-rings-burn.md new file mode 100644 index 0000000000..21119e549b --- /dev/null +++ b/.changeset/short-rings-burn.md @@ -0,0 +1,8 @@ +--- +'@alfalab/core-components-tag': minor +'@alfalab/core-components': minor +--- + +##### Tag + +- Добавлен компонент `IndicatorTag` для тега с числовым индикатором diff --git a/packages/tag/src/utils.ts b/packages/tag/src/utils.ts index 8fbbe93711..c747d2783e 100644 --- a/packages/tag/src/utils.ts +++ b/packages/tag/src/utils.ts @@ -128,7 +128,8 @@ const resolveBadgeRadiusFromIndicatorProps = ( return 0; } - const mode = indicatorProps.mode ?? (typeof indicatorProps.value === 'number' ? 'count' : 'dot'); + const mode = + indicatorProps.mode ?? (typeof indicatorProps.value === 'number' ? 'count' : 'dot'); if (mode === 'dot') { return DOT_RADIUS; @@ -190,9 +191,9 @@ export const resolveGeometry = ({ const junctions: [JunctionPoint, JunctionPoint] | null = dySq >= 0 && dxSq >= 0 ? [ - { cx: edgeRight, cy: badgeY + Math.sqrt(dySq) }, - { cx: badgeX - Math.sqrt(dxSq), cy: edgeTop }, - ] + { cx: edgeRight, cy: badgeY + Math.sqrt(dySq) }, + { cx: badgeX - Math.sqrt(dxSq), cy: edgeTop }, + ] : null; return { badgeX, badgeY, cutoutR, cr, junctions }; From 8a7c04f5ad302b602993648db5956cd376fea0c1 Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Fri, 3 Apr 2026 10:35:29 +0300 Subject: [PATCH 06/11] fix demo --- .../components/indicator-tag/Component.tsx | 1 + .../indicator-tag/default.module.css | 8 + .../indicator-tag/inverted.module.css | 8 + packages/tag/src/docs/description.mdx | 150 ++++++++++++++---- packages/tag/src/utils.ts | 10 +- 5 files changed, 146 insertions(+), 31 deletions(-) diff --git a/packages/tag/src/components/indicator-tag/Component.tsx b/packages/tag/src/components/indicator-tag/Component.tsx index 4ea18afc03..76d2b79347 100644 --- a/packages/tag/src/components/indicator-tag/Component.tsx +++ b/packages/tag/src/components/indicator-tag/Component.tsx @@ -162,6 +162,7 @@ export const IndicatorTag = forwardRef( className={cn( commonStyles.shapeInner, colorStyle.shapeInner, + { [colorStyle.checkedInner]: Boolean(checked) }, childrenClassName, )} > diff --git a/packages/tag/src/components/indicator-tag/default.module.css b/packages/tag/src/components/indicator-tag/default.module.css index 13ca8d24f3..e45709f01d 100644 --- a/packages/tag/src/components/indicator-tag/default.module.css +++ b/packages/tag/src/components/indicator-tag/default.module.css @@ -17,6 +17,14 @@ } } +.checkedInner { + background: var(--tag-background-color-checked); + + & svg { + fill: var(--tag-text-color-checked); + } +} + .badgeIcon { color: var(--tag-text-color); diff --git a/packages/tag/src/components/indicator-tag/inverted.module.css b/packages/tag/src/components/indicator-tag/inverted.module.css index 410c5fd8d2..0283ac6ae5 100644 --- a/packages/tag/src/components/indicator-tag/inverted.module.css +++ b/packages/tag/src/components/indicator-tag/inverted.module.css @@ -17,6 +17,14 @@ } } +.checkedInner { + background: var(--tag-inverted-background-color-checked); + + & svg { + fill: var(--tag-inverted-text-color-checked); + } +} + .badgeIcon { color: var(--tag-inverted-text-color); diff --git a/packages/tag/src/docs/description.mdx b/packages/tag/src/docs/description.mdx index 3d40b8629f..f3bed2b76f 100644 --- a/packages/tag/src/docs/description.mdx +++ b/packages/tag/src/docs/description.mdx @@ -39,7 +39,13 @@ render(() => { ```jsx live render(() => { - const [checked, setChecked] = React.useState(false); + const [checkedMap, setCheckedMap] = React.useState({ + icon: false, + badge: false, + emptyBadge: false, + amount: false, + indicator: false, + }); const addon = (
{ }; const amountAddon = ( -
- +
+
); @@ -70,34 +88,64 @@ render(() => {
- + + setCheckedMap((prev) => ({ ...prev, icon: nextChecked })) + } + {...defaultProps} + > Label
} + checked={checkedMap.badge} + onClick={(_, { checked: nextChecked }) => + setCheckedMap((prev) => ({ ...prev, badge: nextChecked })) + } {...defaultProps} > Label
- } {...defaultProps}> + } + checked={checkedMap.emptyBadge} + onClick={(_, { checked: nextChecked }) => + setCheckedMap((prev) => ({ ...prev, emptyBadge: nextChecked })) + } + {...defaultProps} + > Label
- + + setCheckedMap((prev) => ({ ...prev, amount: nextChecked })) + } + > Label
} - checked={checked} - onClick={(_, { checked: nextChecked }) => setChecked(nextChecked)} + leftAddons={} + checked={checkedMap.indicator} + onClick={(_, { checked: nextChecked }) => + setCheckedMap((prev) => ({ ...prev, indicator: nextChecked })) + } {...defaultProps} />
@@ -107,7 +155,13 @@ render(() => { }); //MOBILE render(() => { - const [checked, setChecked] = React.useState(false); + const [checkedMap, setCheckedMap] = React.useState({ + icon: false, + badge: false, + emptyBadge: false, + amount: false, + indicator: false, + }); const addon = (
{ }; const amountAddon = ( -
- +
+
); @@ -147,34 +213,62 @@ render(() => { }} >
- + + setCheckedMap((prev) => ({ ...prev, icon: nextChecked })) + } + {...defaultProps} + > Label
} + checked={checkedMap.badge} + onClick={(_, { checked: nextChecked }) => + setCheckedMap((prev) => ({ ...prev, badge: nextChecked })) + } {...defaultProps} > Label
- } {...defaultProps}> + } + checked={checkedMap.emptyBadge} + onClick={(_, { checked: nextChecked }) => + setCheckedMap((prev) => ({ ...prev, emptyBadge: nextChecked })) + } + {...defaultProps} + > Label
- + + setCheckedMap((prev) => ({ ...prev, amount: nextChecked })) + } + {...defaultProps} + > Label
} - checked={checked} - onClick={(_, { checked: nextChecked }) => setChecked(nextChecked)} + leftAddons={} + checked={checkedMap.indicator} + onClick={(_, { checked: nextChecked }) => + setCheckedMap((prev) => ({ ...prev, indicator: nextChecked })) + } {...defaultProps} />
@@ -521,6 +615,9 @@ render(() => { min-width: 280px !important; max-width: 280px !important; } + .tag-filter-panel-footer { + padding: 24px 32px 32px !important; + } `}
{ > } + leftAddons={} size={DESKTOP_TAG_SIZE} view='filled' shape='rectangular' - checked={panelOpen} + checked={activeCount > 0} indicatorProps={ activeCount > 0 ? countIndicator @@ -624,6 +721,7 @@ render(() => { componentDivProps={{ style: { top: 16, + right: 12, bottom: 16, maxHeight: 'calc(100vh - 32px)', boxSizing: 'border-box', @@ -806,8 +904,8 @@ render(() => {
} - -
+ +
@@ -951,7 +1037,7 @@ const DYNAMIC_META = { autopay: 'Автоплатежи', }; -const emptyFilters = () => ({ +const EMPTY = () => ({ replenishment: false, writeoff: false, dynamicKeys: [], @@ -959,124 +1045,134 @@ const emptyFilters = () => ({ accounts: [], }); -const countActive = (f) => { - let n = 0; - if (f.replenishment) n += 1; - if (f.writeoff) n += 1; - n += f.dynamicKeys.length; - if (f.period) n += 1; - n += f.accounts.length; - return n; -}; +const toggleInArray = (arr, value) => + arr.includes(value) ? arr.filter((item) => item !== value) : [...arr, value]; + +const countActive = (filters) => + [ + filters.replenishment, + filters.writeoff, + filters.period, + ...filters.dynamicKeys, + ...filters.accounts, + ].filter(Boolean).length; const MOBILE_ROW_TAG_SIZE = 32; +const OPTIONS_LIST_ROW_SIZE = 40; + +const DYNAMIC_GROUPS = [ + { + title: 'Движение средств', + items: [ + { key: 'replenishment', label: 'Пополнение', type: 'boolean' }, + { key: 'writeoff', label: 'Списание', type: 'boolean' }, + { key: 'added_expenses', label: DYNAMIC_META.added_expenses, type: 'dynamic' }, + ], + }, + { + title: 'Переводы', + items: [ + { key: 'to_client', label: DYNAMIC_META.to_client, type: 'dynamic' }, + { key: 'between_accounts', label: DYNAMIC_META.between_accounts, type: 'dynamic' }, + ], + }, + { + title: 'Шаблоны и автоплатежи', + items: [ + { key: 'templates', label: DYNAMIC_META.templates, type: 'dynamic' }, + { key: 'autopay', label: DYNAMIC_META.autopay, type: 'dynamic' }, + ], + }, +]; + +const FilterTagItem = ({ checked, onClick, size = MOBILE_ROW_TAG_SIZE, children }) => ( + + {children} + +); + +const CheckboxItem = ({ selected, disabled, position }) => ( + + + +); render(() => { - const TagFieldSelectArrow = ({ open, size }) => { - const ChevronIcon = size === 40 ? ChevronDownSIcon : ChevronDownMIcon; + const [panelOpen, setPanelOpen] = React.useState(false); + const [countIndicator, setCountIndicator] = React.useState(false); + const [applied, setApplied] = React.useState(EMPTY); + const [draft, setDraft] = React.useState(EMPTY); + const [filterSheet, setFilterSheet] = React.useState(null); + const [accountsSheetDraft, setAccountsSheetDraft] = React.useState([]); + const prevFilterSheetRef = React.useRef(null); - return ( - - ); - }; + const updateApplied = (patch) => setApplied((prev) => ({ ...prev, ...patch })); + const updateDraft = (patch) => setDraft((prev) => ({ ...prev, ...patch })); - const TagField = (props) => { - const { - open, - disabled, - innerProps, - valueRenderer, - selected, - selectedMultiple, - placeholder, - Arrow, - size = MOBILE_ROW_TAG_SIZE, - } = props; - const value = valueRenderer({ - selected, - selectedMultiple: selectedMultiple || [], - }); - const filled = Boolean(value); - const display = filled ? value : placeholder; - const checked = open || filled; - - return ( -
- - {display} - -
- ); - }; + const closeFilterSheet = () => setFilterSheet(null); - const [panelOpen, setPanelOpen] = React.useState(false); - const [countIndicator, setCountIndicator] = React.useState(false); - const [applied, setApplied] = React.useState(emptyFilters); - const [draft, setDraft] = React.useState(emptyFilters); + const toggleFilterSheet = (name) => { + setFilterSheet((prev) => (prev === name ? null : name)); + }; - const openOrTogglePanel = () => { - if (panelOpen) { + React.useEffect(() => { + if (filterSheet) { setPanelOpen(false); - } else { - setDraft({ ...applied }); - setPanelOpen(true); } - }; + }, [filterSheet]); - const activeCount = countActive(applied); + React.useEffect(() => { + if (filterSheet === 'accounts' && prevFilterSheetRef.current !== 'accounts') { + setAccountsSheetDraft([...applied.accounts]); + } + prevFilterSheetRef.current = filterSheet; + }, [filterSheet, applied.accounts]); - const toggleDraftDynamic = (id) => { - setDraft((prev) => { - const has = prev.dynamicKeys.includes(id); - if (has) { - return { ...prev, dynamicKeys: prev.dynamicKeys.filter((k) => k !== id) }; - } - return { ...prev, dynamicKeys: [...prev.dynamicKeys, id] }; + const openOrTogglePanel = () => { + setFilterSheet(null); + setPanelOpen((isOpen) => { + if (isOpen) return false; + + setDraft({ ...applied }); + return true; }); }; - const toggleAppliedDynamic = (id) => { - setApplied((prev) => ({ - ...prev, - dynamicKeys: prev.dynamicKeys.filter((k) => k !== id), - })); + const closePanel = () => { + setPanelOpen(false); + setFilterSheet(null); }; + const activeCount = countActive(applied); - const periodValueRenderer = ({ selected }) => { - if (!selected) return null; - return `Период: ${selected.content}`; - }; + const toggleDraftDynamic = (key) => + updateDraft({ + dynamicKeys: toggleInArray(draft.dynamicKeys, key), + }); - const accountsValueRenderer = ({ selectedMultiple }) => { - if (!selectedMultiple.length) return null; - if (selectedMultiple.length === 1) return `Счета: ${selectedMultiple[0].content}`; - return `Счета: Выбрано ${selectedMultiple.length}`; - }; + const appliedPeriodOption = React.useMemo( + () => PERIOD_OPTIONS.find((o) => o.key === applied.period), + [applied.period], + ); - const selectRowProps = { - allowUnselect: true, - block: false, - size: MOBILE_ROW_TAG_SIZE, - Option: BaseOption, - Field: TagField, - Arrow: TagFieldSelectArrow, - optionsListWidth: 'field', - }; + const accountsSelectedOpts = React.useMemo( + () => ACCOUNT_OPTIONS.filter((o) => applied.accounts.includes(o.key)), + [applied.accounts], + ); const mobileFilterTagsScrollRowStyle = { display: 'flex', @@ -1091,6 +1187,11 @@ render(() => { paddingTop: 10, paddingBottom: 4, boxSizing: 'border-box', + width: 'max-content', + maxWidth: '100%', + minWidth: 0, + scrollbarWidth: 'none', + msOverflowStyle: 'none', }; const mobileFilterSheetTagsRowStyle = { @@ -1103,100 +1204,155 @@ render(() => { return ( -
-
- } - size={MOBILE_ROW_TAG_SIZE} - view='filled' - shape='rectangular' - checked={activeCount > 0} - indicatorProps={ - activeCount > 0 - ? countIndicator - ? { mode: 'count', value: activeCount } - : { mode: 'dot' } - : undefined - } - onClick={openOrTogglePanel} - /> -
- - {applied.dynamicKeys.map((id) => ( -
+ +
+
+
} size={MOBILE_ROW_TAG_SIZE} view='filled' shape='rectangular' - checked={true} - onClick={() => toggleAppliedDynamic(id)} + checked={false} + indicatorProps={ + activeCount > 0 + ? countIndicator + ? { mode: 'count', value: activeCount } + : { mode: 'dot' } + : undefined + } + onClick={openOrTogglePanel} + /> +
+ + {applied.dynamicKeys.map((id) => ( +
+ + updateApplied({ + dynamicKeys: applied.dynamicKeys.filter( + (key) => key !== id, + ), + }) + } + > + {DYNAMIC_META[id]} + +
+ ))} + +
+ updateApplied({ replenishment: !applied.replenishment })} > - {DYNAMIC_META[id]} - + Пополнения + +
+
+ updateApplied({ writeoff: !applied.writeoff })} + > + Списания +
- ))} -
- - setApplied((p) => ({ ...p, replenishment: !p.replenishment })) - } - > - Пополнения - -
-
- setApplied((p) => ({ ...p, writeoff: !p.writeoff }))} - > - Списания - +
+ toggleFilterSheet('period')} + > + {appliedPeriodOption ? ( + <> + Период: + + {appliedPeriodOption.content} + + + ) : ( + 'Период' + )} + +
+
+ 0} + open={filterSheet === 'accounts'} + onClick={() => toggleFilterSheet('accounts')} + > + {accountsSelectedOpts.length === 0 ? ( + 'Счета' + ) : accountsSelectedOpts.length === 1 ? ( + <> + Счета: + + {accountsSelectedOpts[0].content} + + + ) : ( + <> + Счета: + + Выбрано {accountsSelectedOpts.length} + + + )} + +
-
- { - setApplied((p) => ({ ...p, period: next ? next.key : null })); - }} - /> -
-
- { - setApplied((p) => ({ - ...p, - accounts: selectedMultiple.map((o) => o.key), - })); - }} - /> + + +
+
- - - - - + { onChange={() => setCountIndicator((v) => !v)} /> + +
+ item.key === applied.period)} + getOptionProps={(option, index) => ({ + option, + index, + mobile: true, + multiple: false, + selected: applied.period === option.key, + innerProps: { + id: `tag-doc-mobile-period-sheet-${option.key}`, + role: 'option', + onMouseDown: (e) => e.preventDefault(), + onClick: () => { + updateApplied({ + period: applied.period === option.key ? null : option.key, + }); + closeFilterSheet(); + }, + }, + })} + /> +
+
+ + + setAccountsSheetDraft([])} + > + Сбросить + + { + updateApplied({ accounts: accountsSheetDraft }); + closeFilterSheet(); + }} + > + Применить + +
+ } + > +
+ + accountsSheetDraft.includes(item.key), + )} + getOptionProps={(option, index) => ({ + option, + index, + mobile: true, + multiple: true, + Checkmark: CheckboxItem, + selected: accountsSheetDraft.includes(option.key), + innerProps: { + id: `tag-doc-mobile-accounts-sheet-${option.key}`, + role: 'option', + onMouseDown: (e) => e.preventDefault(), + onClick: () => { + setAccountsSheetDraft((prev) => + toggleInArray(prev, option.key), + ); + }, + }, + })} + /> +
+ + setPanelOpen(false)} + onClose={closePanel} title='Фильтры' hasCloser={true} stickyFooter={true} @@ -1227,7 +1501,7 @@ render(() => { type='button' block={true} style={{ flex: 1 }} - onClick={() => setDraft(emptyFilters())} + onClick={() => setDraft(EMPTY)} > Сбросить @@ -1239,7 +1513,7 @@ render(() => { style={{ flex: 1 }} onClick={() => { setApplied({ ...draft }); - setPanelOpen(false); + closePanel(); }} > Применить @@ -1280,126 +1554,48 @@ render(() => { - - Движение средств - - -
-
- - setDraft((p) => ({ ...p, replenishment: !p.replenishment })) - } - > - Пополнение - -
-
- setDraft((p) => ({ ...p, writeoff: !p.writeoff }))} - > - Списание - -
-
- toggleDraftDynamic('added_expenses')} - > - Добавленные расходы - -
-
- - - - - Переводы - - -
-
- toggleDraftDynamic('to_client')} - > - Клиенту банка - -
-
- toggleDraftDynamic('between_accounts')} - > - Между счетами - -
-
- - - - - Шаблоны и автоплатежи - - -
-
- toggleDraftDynamic('templates')} + {DYNAMIC_GROUPS.map((group) => ( + + - Шаблоны - -
-
- toggleDraftDynamic('autopay')} - > - Автоплатежи - -
-
- - + {group.title} + + +
+ {group.items.map((item) => { + const checked = + item.type === 'boolean' + ? Boolean(draft[item.key]) + : draft.dynamicKeys.includes(item.key); + + return ( +
+ { + if (item.type === 'boolean') { + updateDraft({ + [item.key]: !draft[item.key], + }); + return; + } + + toggleDraftDynamic(item.key); + }} + > + {item.label} + +
+ ); + })} +
+ + + ))} { checked={draft.accounts.includes(opt.key)} label={opt.content} onChange={() => - setDraft((p) => { - const has = p.accounts.includes(opt.key); - return { - ...p, - accounts: has - ? p.accounts.filter((k) => k !== opt.key) - : [...p.accounts, opt.key], - }; + updateDraft({ + accounts: toggleInArray(draft.accounts, opt.key), }) } /> diff --git a/packages/tag/src/utils.ts b/packages/tag/src/utils.ts index 7b6fe9bbba..50b712fdff 100644 --- a/packages/tag/src/utils.ts +++ b/packages/tag/src/utils.ts @@ -9,15 +9,17 @@ const OFFSET = 3; const SIN_45 = Math.SQRT2 / 2; /** Радиус круга в точках пересечения эллипса и окружности */ -export const JR = 2; +export const JR = 4.5; /** Сторона чёрного квадрата-резца в точках пересечения эллипса и окружности */ -export const JR_RECT = 4; +export const JR_RECT = 6; const EPSILON = 1e-3; const DOUBLE_INDICATOR_SHIFT_X = 3; const TRIPLE_INDICATOR_SHIFT_X = 6; -const INDICATOR_INSET_X = 2; -const INDICATOR_INSET_Y = 2; +const INDICATOR_INSET_X = 3; +const INDICATOR_INSET_Y = 3; + +const RECT_CORNER_RADIUS_FACTOR = 0.31; type BadgeIconSize = NonNullable; @@ -162,12 +164,15 @@ export const resolveGeometry = ({ badgeX: 0, badgeY: 0, cutoutR: 0, - cr: shape === 'rectangular' ? Math.round(Math.min(w, h) * 0.22) : undefined, + cr: + shape === 'rectangular' + ? Math.round(Math.min(w, h) * RECT_CORNER_RADIUS_FACTOR) + : undefined, junctions: null, }; } - const cutoutR = badgeRadius + 2; + const cutoutR = badgeRadius + 1; const outerR = cutoutR + JR; /* @@ -178,7 +183,7 @@ export const resolveGeometry = ({ * Если подкоренные выражения отрицательные, стыка нет (junctions = null). */ if (shape === 'rectangular') { - const cr = Math.round(Math.min(w, h) * 0.22); + const cr = Math.round(Math.min(w, h) * RECT_CORNER_RADIUS_FACTOR); const badgeX = w - cr + (cr + OFFSET) * SIN_45 - INDICATOR_INSET_X; const badgeY = cr - (cr + OFFSET) * SIN_45 + INDICATOR_INSET_Y; diff --git a/packages/tag/src/vars.css b/packages/tag/src/vars.css index 7f7b4fce51..637d6d4d9b 100644 --- a/packages/tag/src/vars.css +++ b/packages/tag/src/vars.css @@ -44,6 +44,7 @@ --tag-filled-background-color-hover: var(--color-light-neutral-translucent-300-hover); --tag-filled-background-color-active: var(--color-light-neutral-translucent-300-press); --tag-filled-background-color-disabled: var(--color-light-neutral-translucent-100); + --tag-filled-mask-positive-color: var(--color-static-neutral-0); /* background-color checked */ --tag-background-color-checked: var(--color-light-accent-secondary); @@ -51,12 +52,6 @@ --tag-background-color-checked-active: var(--color-light-accent-secondary-press); --tag-background-color-checked-disabled: var(--color-light-neutral-translucent-100); - /* background-color indicator-tag (badge icon) */ - --tag-badge-icon-background-color: var(--color-light-neutral-translucent-300); - --tag-badge-icon-background-color-hover: var(--color-light-neutral-translucent-300-hover); - --tag-badge-icon-background-color-active: var(--color-light-neutral-translucent-300-press); - --tag-badge-icon-mask-positive-color: var(--color-static-neutral-0); - /* text color */ --tag-text-color: var(--color-light-text-primary); --tag-text-color-checked: var(--color-light-text-primary-inverted); @@ -106,6 +101,7 @@ --tag-inverted-filled-background-color-active: var( --color-light-neutral-translucent-300-inverted-press ); + --tag-inverted-filled-mask-positive-color: var(--color-static-neutral-0); /* background-color checked */ --tag-inverted-background-color-checked: var(--color-light-accent-secondary-inverted); @@ -119,16 +115,6 @@ --color-light-neutral-translucent-100-inverted ); - /* background-color indicator-tag (badge icon) */ - --tag-badge-icon-inverted-background-color: var(--color-light-neutral-translucent-300-inverted); - --tag-badge-icon-inverted-background-color-hover: var( - --color-light-neutral-translucent-300-inverted-hover - ); - --tag-badge-icon-inverted-background-color-active: var( - --color-light-neutral-translucent-300-inverted-press - ); - --tag-badge-icon-mask-positive-color-inverted: var(--color-static-neutral-0); - /* text color */ --tag-inverted-text-color: var(--color-light-text-primary-inverted); --tag-inverted-text-color-checked: var(--color-light-text-primary); @@ -141,7 +127,7 @@ --tag-rectangular-mobile-xs-border-radius: var(--border-radius-12); --tag-rectangular-mobile-s-border-radius: var(--border-radius-12); --tag-filled-mobile-background-color: var(--color-light-neutral-translucent-100); - --tag-filled-mobile-background-color-hover: var(--color-light-neutral-translucent-100); + --tag-filled-mobile-background-color-hover: var(--color-light-neutral-translucent-100-hover); --tag-filled-mobile-background-color-active: var(--color-light-neutral-translucent-100-press); --tag-filled-mobile-background-color-disabled: var(--color-light-neutral-translucent-100); @@ -150,7 +136,7 @@ --color-light-neutral-translucent-100-inverted ); --tag-inverted-filled-mobile-background-color-hover: var( - --color-light-neutral-translucent-100-inverted + --color-light-neutral-translucent-100-inverted-hover ); --tag-inverted-filled-mobile-background-color-active: var( --color-light-neutral-translucent-100-inverted-press From a7a12cd1bb9c4494c291ed978a45d8662c194d85 Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Thu, 30 Apr 2026 00:00:54 +0300 Subject: [PATCH 08/11] fix screenshots --- packages/tag/src/components/native-tag/default.module.css | 8 -------- .../tag/src/components/native-tag/inverted.module.css | 8 -------- 2 files changed, 16 deletions(-) diff --git a/packages/tag/src/components/native-tag/default.module.css b/packages/tag/src/components/native-tag/default.module.css index 5356816b71..7a32bf86b3 100644 --- a/packages/tag/src/components/native-tag/default.module.css +++ b/packages/tag/src/components/native-tag/default.module.css @@ -77,13 +77,5 @@ background-color: var(--tag-background-color-checked-active); border-color: var(--tag-background-color-checked-active); } - - &:disabled span { - color: var(--tag-text-color-checked-disabled); - } - - &:not(:disabled) span { - color: var(--tag-text-color-checked); - } } } diff --git a/packages/tag/src/components/native-tag/inverted.module.css b/packages/tag/src/components/native-tag/inverted.module.css index 24e8a67440..43712d97f9 100644 --- a/packages/tag/src/components/native-tag/inverted.module.css +++ b/packages/tag/src/components/native-tag/inverted.module.css @@ -77,13 +77,5 @@ background-color: var(--tag-inverted-background-color-checked-active); border-color: var(--tag-inverted-background-color-checked-active); } - - &:disabled span { - color: var(--tag-inverted-text-color-checked-disabled); - } - - &:not(:disabled) span { - color: var(--tag-inverted-text-color-checked); - } } } From fee547c74860b593ac1b63c293e3651fff60b0be Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Mon, 4 May 2026 09:51:33 +0300 Subject: [PATCH 09/11] fix demo, improvement mobile indicatorTag styles --- .../components/indicator-tag/Component.tsx | 5 +- .../indicator-tag/default.module.css | 1 - .../indicator-tag/inverted.module.css | 1 - packages/tag/src/docs/description.mdx | 75 +++++++++++++++++-- packages/tag/src/utils.ts | 2 +- 5 files changed, 72 insertions(+), 12 deletions(-) diff --git a/packages/tag/src/components/indicator-tag/Component.tsx b/packages/tag/src/components/indicator-tag/Component.tsx index 76d2b79347..8253a6c31e 100644 --- a/packages/tag/src/components/indicator-tag/Component.tsx +++ b/packages/tag/src/components/indicator-tag/Component.tsx @@ -3,7 +3,7 @@ import cn from 'classnames'; import { Indicator } from '@alfalab/core-components-indicator'; -import { type BaseTagProps } from '../../typings'; +import { type BaseTagProps, type StyleColors } from '../../typings'; import { JR, JR_RECT, @@ -36,6 +36,7 @@ export interface IndicatorTagProps | 'size' > { size: 32 | 40; + colorStyles?: StyleColors['default']; onClick?: MouseEventHandler; focused?: boolean; } @@ -56,6 +57,7 @@ export const IndicatorTag = forwardRef( dataTestId, onClick, focused = false, + colorStyles, ...restProps }, ref, @@ -162,6 +164,7 @@ export const IndicatorTag = forwardRef( className={cn( commonStyles.shapeInner, colorStyle.shapeInner, + colorStyles?.filled, { [colorStyle.checkedInner]: Boolean(checked) }, childrenClassName, )} diff --git a/packages/tag/src/components/indicator-tag/default.module.css b/packages/tag/src/components/indicator-tag/default.module.css index 9811b0ed65..941a7f3d0f 100644 --- a/packages/tag/src/components/indicator-tag/default.module.css +++ b/packages/tag/src/components/indicator-tag/default.module.css @@ -5,7 +5,6 @@ } .shapeInner { - background-color: var(--tag-filled-background-color); color: inherit; & svg { diff --git a/packages/tag/src/components/indicator-tag/inverted.module.css b/packages/tag/src/components/indicator-tag/inverted.module.css index 478ace9376..20d974283a 100644 --- a/packages/tag/src/components/indicator-tag/inverted.module.css +++ b/packages/tag/src/components/indicator-tag/inverted.module.css @@ -5,7 +5,6 @@ } .shapeInner { - background-color: var(--tag-inverted-filled-background-color); color: inherit; & svg { diff --git a/packages/tag/src/docs/description.mdx b/packages/tag/src/docs/description.mdx index 4665ab5e31..3cbdf7ab28 100644 --- a/packages/tag/src/docs/description.mdx +++ b/packages/tag/src/docs/description.mdx @@ -68,6 +68,11 @@ render(() => { const amountAddon = ( { }} > ); return ( - + + +
{
} + leftAddons={} checked={checkedMap.indicator} onClick={(_, { checked: nextChecked }) => setCheckedMap((prev) => ({ ...prev, indicator: nextChecked })) @@ -151,7 +176,8 @@ render(() => { />
- + + ); }); //MOBILE @@ -185,6 +211,11 @@ render(() => { const amountAddon = ( { }} > ); return ( - + + +
{
} + leftAddons={} checked={checkedMap.indicator} onClick={(_, { checked: nextChecked }) => setCheckedMap((prev) => ({ ...prev, indicator: nextChecked })) @@ -275,7 +326,8 @@ render(() => { />
-
+
+ ); }); ``` @@ -559,6 +611,13 @@ render(() => { } .tag-filter-row-options { border-radius: 16px; + border: 1px solid var(--neutral-300, #E7E8EB); + background: var(--modal-bg-primary, #FFF); + box-shadow: + 0 20px 24px 0 rgba(0, 0, 0, 0.08), + 0 12px 16px 0 rgba(0, 0, 0, 0.04), + 0 4px 8px 0 rgba(0, 0, 0, 0.04), + 0 0 1px 0 rgba(0, 0, 0, 0.04); overflow: hidden; } .tag-filter-panel-footer { diff --git a/packages/tag/src/utils.ts b/packages/tag/src/utils.ts index 50b712fdff..9ed17ce952 100644 --- a/packages/tag/src/utils.ts +++ b/packages/tag/src/utils.ts @@ -19,7 +19,7 @@ const TRIPLE_INDICATOR_SHIFT_X = 6; const INDICATOR_INSET_X = 3; const INDICATOR_INSET_Y = 3; -const RECT_CORNER_RADIUS_FACTOR = 0.31; +const RECT_CORNER_RADIUS_FACTOR = 0.21; type BadgeIconSize = NonNullable; From 6f25df70549f6b6e6b0442189352e27097b44ec7 Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Fri, 8 May 2026 11:52:35 +0300 Subject: [PATCH 10/11] =?UTF-8?q?fix(tag):=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20shape=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tag/src/Component.test.tsx | 73 ++++++++++++++++-- .../components/indicator-tag/Component.tsx | 20 +++-- packages/tag/src/utils.ts | 77 ++++++++++++++----- 3 files changed, 134 insertions(+), 36 deletions(-) diff --git a/packages/tag/src/Component.test.tsx b/packages/tag/src/Component.test.tsx index a169a15994..bfe8c7fa82 100644 --- a/packages/tag/src/Component.test.tsx +++ b/packages/tag/src/Component.test.tsx @@ -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', () => { @@ -84,13 +86,13 @@ describe('Attributes tests', () => { }); describe('Render tests', () => { - test('should unmount without errors', () => { + it('should unmount without errors', () => { const { unmount } = render(addons
}>Tag); expect(unmount).not.toThrow(); }); - test('should contain right addons', () => { + it('should contain right addons', () => { const rightAddonText = 'Right addon text'; const { container, getByText } = render( @@ -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( @@ -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( + } + />, + ); + 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( + } + />, + ); + 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(Press me!); @@ -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( @@ -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( diff --git a/packages/tag/src/components/indicator-tag/Component.tsx b/packages/tag/src/components/indicator-tag/Component.tsx index 8253a6c31e..c607c3c4a6 100644 --- a/packages/tag/src/components/indicator-tag/Component.tsx +++ b/packages/tag/src/components/indicator-tag/Component.tsx @@ -5,8 +5,6 @@ import { Indicator } from '@alfalab/core-components-indicator'; import { type BaseTagProps, type StyleColors } from '../../typings'; import { - JR, - JR_RECT, resolveGeometry, resolveSizeToDimensions, resolveValueToIndicatorShiftX, @@ -77,7 +75,7 @@ export const IndicatorTag = forwardRef( const { width, height } = resolveSizeToDimensions(size); - const { badgeX, badgeY, cutoutR, cr, junctions } = resolveGeometry({ + const { badgeX, badgeY, cutoutR, cr, junctionR, junctionRect, junctions } = resolveGeometry({ width, height, shape, @@ -130,27 +128,27 @@ export const IndicatorTag = forwardRef( ) : null} diff --git a/packages/tag/src/utils.ts b/packages/tag/src/utils.ts index 9ed17ce952..c888c9deb8 100644 --- a/packages/tag/src/utils.ts +++ b/packages/tag/src/utils.ts @@ -5,14 +5,30 @@ type JunctionPoint = { cx: number; cy: number }; export const DOT_RADIUS = 5; export const BADGE_NUMBER_RADIUS = 8; -const OFFSET = 3; +const OFFSET = 4; +const DOT_INDICATOR_RADIUS = 4; +const DOT_INDICATOR_GAP = 1; + +const BADGE_NUMBER_GAP = 3; + +const CIRCLE_INDICATOR_OFFSET = 6; +const CIRCLE_INDICATOR_INSET_X = 2; +const CIRCLE_INDICATOR_INSET_Y = 2; + +const CIRCLE_DOT_INDICATOR_OFFSET = 6; +const CIRCLE_DOT_INDICATOR_INSET_X = 3; +const CIRCLE_DOT_INDICATOR_INSET_Y = 4; + const SIN_45 = Math.SQRT2 / 2; /** Радиус круга в точках пересечения эллипса и окружности */ -export const JR = 4.5; +export const JR = 4; /** Сторона чёрного квадрата-резца в точках пересечения эллипса и окружности */ export const JR_RECT = 6; +const COUNT_JR = 2; +const COUNT_JR_RECT = 4; + const EPSILON = 1e-3; const DOUBLE_INDICATOR_SHIFT_X = 3; const TRIPLE_INDICATOR_SHIFT_X = 6; @@ -142,11 +158,24 @@ const resolveBadgeRadiusFromIndicatorProps = ( return typeof indicatorProps.value === 'number' ? BADGE_NUMBER_RADIUS : DOT_RADIUS; }; +const isDotIndicator = (indicatorProps: BaseTagProps['indicatorProps'] | undefined): boolean => { + if (indicatorProps === undefined) { + return false; + } + + const mode = + indicatorProps.mode ?? (typeof indicatorProps.value === 'number' ? 'count' : 'dot'); + + return mode === 'dot'; +}; + type BadgeGeometry = { badgeX: number; badgeY: number; cutoutR: number; cr?: number; + junctionR: number; + junctionRect: number; junctions: [JunctionPoint, JunctionPoint] | null; }; @@ -168,12 +197,17 @@ export const resolveGeometry = ({ shape === 'rectangular' ? Math.round(Math.min(w, h) * RECT_CORNER_RADIUS_FACTOR) : undefined, + junctionR: JR, + junctionRect: JR_RECT, junctions: null, }; } - const cutoutR = badgeRadius + 1; - const outerR = cutoutR + JR; + const dotIndicator = isDotIndicator(indicatorProps); + const junctionR = dotIndicator ? JR : COUNT_JR; + const junctionRect = dotIndicator ? JR_RECT : COUNT_JR_RECT; + const cutoutR = badgeRadius + (dotIndicator ? DOT_INDICATOR_GAP : BADGE_NUMBER_GAP); + const outerR = cutoutR + junctionR; /* * Rectangular: центр бейджа сдвигаем в правый верхний угол по диагонали 45° относительно скругления (cr). @@ -184,42 +218,49 @@ export const resolveGeometry = ({ */ if (shape === 'rectangular') { const cr = Math.round(Math.min(w, h) * RECT_CORNER_RADIUS_FACTOR); - const badgeX = w - cr + (cr + OFFSET) * SIN_45 - INDICATOR_INSET_X; - const badgeY = cr - (cr + OFFSET) * SIN_45 + INDICATOR_INSET_Y; - - const edgeRight = w - JR; + const badgeX = dotIndicator + ? w - DOT_INDICATOR_RADIUS + : w - cr + (cr + OFFSET) * SIN_45 - INDICATOR_INSET_X; + const badgeY = dotIndicator + ? DOT_INDICATOR_RADIUS + : cr - (cr + OFFSET) * SIN_45 + INDICATOR_INSET_Y; + + const edgeRight = w - junctionR; const dx = edgeRight - badgeX; const dySq = outerR * outerR - dx * dx; - const edgeTop = JR; + const edgeTop = junctionR; const dy = edgeTop - badgeY; const dxSq = outerR * outerR - dy * dy; const junctions: [JunctionPoint, JunctionPoint] | null = dySq >= 0 && dxSq >= 0 ? [ - { cx: edgeRight, cy: badgeY + Math.sqrt(dySq) }, - { cx: badgeX - Math.sqrt(dxSq), cy: edgeTop }, - ] + { cx: edgeRight, cy: badgeY + Math.sqrt(dySq) }, + { cx: badgeX - Math.sqrt(dxSq), cy: edgeTop }, + ] : null; - return { badgeX, badgeY, cutoutR, cr, junctions }; + return { badgeX, badgeY, cutoutR, cr, junctionR, junctionRect, junctions }; } const rx = w / 2; const ry = h / 2; - const badgeX = rx + (rx + OFFSET) * SIN_45 - INDICATOR_INSET_X; - const badgeY = ry - (ry + OFFSET) * SIN_45 + INDICATOR_INSET_Y; + const circleOffset = dotIndicator ? CIRCLE_DOT_INDICATOR_OFFSET : CIRCLE_INDICATOR_OFFSET; + const circleInsetX = dotIndicator ? CIRCLE_DOT_INDICATOR_INSET_X : CIRCLE_INDICATOR_INSET_X; + const circleInsetY = dotIndicator ? CIRCLE_DOT_INDICATOR_INSET_Y : CIRCLE_INDICATOR_INSET_Y; + const badgeX = rx + (rx + circleOffset) * SIN_45 - circleInsetX; + const badgeY = ry - (ry + circleOffset) * SIN_45 + circleInsetY; const junctions = intersectEllipseCircle({ ecx: rx, ecy: ry, - erx: rx - JR, - ery: ry - JR, + erx: rx - junctionR, + ery: ry - junctionR, ccx: badgeX, ccy: badgeY, cr: outerR, }); - return { badgeX, badgeY, cutoutR, junctions }; + return { badgeX, badgeY, cutoutR, junctionR, junctionRect, junctions }; }; From 0beea977f14ee5b3562e78b06b6343ff73c8e7a5 Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Fri, 8 May 2026 12:03:47 +0300 Subject: [PATCH 11/11] fix esLint --- .../tag/src/components/indicator-tag/Component.tsx | 14 ++++++++------ packages/tag/src/utils.ts | 6 +++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/tag/src/components/indicator-tag/Component.tsx b/packages/tag/src/components/indicator-tag/Component.tsx index c607c3c4a6..a6c7251c91 100644 --- a/packages/tag/src/components/indicator-tag/Component.tsx +++ b/packages/tag/src/components/indicator-tag/Component.tsx @@ -75,12 +75,14 @@ export const IndicatorTag = forwardRef( const { width, height } = resolveSizeToDimensions(size); - const { badgeX, badgeY, cutoutR, cr, junctionR, junctionRect, junctions } = resolveGeometry({ - width, - height, - shape, - indicatorProps, - }); + const { badgeX, badgeY, cutoutR, cr, junctionR, junctionRect, junctions } = resolveGeometry( + { + width, + height, + shape, + indicatorProps, + }, + ); const buttonProps = { className: cn(commonStyles.badgeIcon, colorStyle.badgeIcon, className, { diff --git a/packages/tag/src/utils.ts b/packages/tag/src/utils.ts index c888c9deb8..80b89e36f3 100644 --- a/packages/tag/src/utils.ts +++ b/packages/tag/src/utils.ts @@ -236,9 +236,9 @@ export const resolveGeometry = ({ const junctions: [JunctionPoint, JunctionPoint] | null = dySq >= 0 && dxSq >= 0 ? [ - { cx: edgeRight, cy: badgeY + Math.sqrt(dySq) }, - { cx: badgeX - Math.sqrt(dxSq), cy: edgeTop }, - ] + { cx: edgeRight, cy: badgeY + Math.sqrt(dySq) }, + { cx: badgeX - Math.sqrt(dxSq), cy: edgeTop }, + ] : null; return { badgeX, badgeY, cutoutR, cr, junctionR, junctionRect, junctions };