Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(): add cross icon in input and combobox (WIP) #3526

Open
wants to merge 2 commits into
base: 5.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions packages/react-ui/components/Input/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export interface InputProps
Override<
React.InputHTMLAttributes<HTMLInputElement>,
{
/** Устанавливает иконку крестика, при нажатии на который инпут очищается. */
showCleanCross?: boolean;
/**
* Иконка слева
* Если `ReactNode` применяются дефолтные стили для иконки
Expand Down Expand Up @@ -157,6 +159,7 @@ export interface InputState {
blinking: boolean;
focused: boolean;
needsPolyfillPlaceholder: boolean;
value: string;
}

export const InputDataTids = {
Expand Down Expand Up @@ -185,6 +188,7 @@ export class Input extends React.Component<InputProps, InputState> {
needsPolyfillPlaceholder,
blinking: false,
focused: false,
value: this.props.value ?? '',
};

private selectAllId: number | null = null;
Expand All @@ -201,10 +205,19 @@ export class Input extends React.Component<InputProps, InputState> {
this.outputMaskError();
}

public componentDidUpdate(prevProps: Readonly<InputProps>) {
public getSnapshotBeforeUpdate() {
return this.input?.value;
}
public componentDidUpdate(prevProps: Readonly<InputProps>, prevState: Readonly<InputState>, snapshot: string) {
if (this.props.type !== prevProps.type || this.props.mask !== prevProps.mask) {
this.outputMaskError();
}
if (this.state.value !== snapshot) {
if (this.input) {
this.input.value = this.state.value;
}
}
console.log('update');
}

public componentWillUnmount() {
Expand Down Expand Up @@ -276,7 +289,7 @@ export class Input extends React.Component<InputProps, InputState> {
if (globalObject.document?.activeElement !== this.input) {
this.focus();
}
if (this.props.mask && this.props.value && this.props.value?.length < this.props.mask.length) {
if (this.props.mask && this.state.value && this.state.value?.length < this.props.mask.length) {
globalObject.setTimeout(() => {
this.input?.setSelectionRange(start, end);
}, 150);
Expand Down Expand Up @@ -373,7 +386,7 @@ export class Input extends React.Component<InputProps, InputState> {
leftIcon,
rightIcon,
borderless,
value,
showCleanCross,
align,
type,
mask,
Expand All @@ -395,7 +408,7 @@ export class Input extends React.Component<InputProps, InputState> {
...rest
} = props;

const { blinking, focused } = this.state;
const { blinking, focused, value } = this.state;

const labelProps = {
className: cx(styles.root(this.theme), this.getSizeClassName(), {
Expand Down Expand Up @@ -425,7 +438,10 @@ export class Input extends React.Component<InputProps, InputState> {
}),
value,
role,
onChange: this.handleChange,
onChange: (e) => {
this.setState({ value: e.target.value });
this.handleChange(e);
},
onFocus: this.handleFocus,
onKeyDown: this.handleKeyDown,
onKeyPress: this.handleKeyPress,
Expand All @@ -447,6 +463,11 @@ export class Input extends React.Component<InputProps, InputState> {
<InputLayout
leftIcon={leftIcon}
rightIcon={rightIcon}
clearInput={() => {
this.state.value = '';
this.input?.focus();
}}
showCleanCross={showCleanCross && !!this.input?.value}
prefix={prefix}
suffix={suffix}
labelProps={labelProps}
Expand Down Expand Up @@ -570,7 +591,7 @@ export class Input extends React.Component<InputProps, InputState> {
}
};

private handleUnexpectedInput = (value: string = this.props.value || '') => {
private handleUnexpectedInput = (value: string = this.state.value || '') => {
if (this.props.onUnexpectedInput) {
this.props.onUnexpectedInput(value);
} else {
Expand All @@ -581,7 +602,10 @@ export class Input extends React.Component<InputProps, InputState> {
private resetFocus = () => this.setState({ focused: false });

private handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
console.log('blur');
// if (!event.currentTarget.contains(event.relatedTarget)) {
this.resetFocus();
this.props.onBlur?.(event);
// }
};
}
17 changes: 14 additions & 3 deletions packages/react-ui/components/Input/InputLayout/InputLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,36 @@ import { CommonProps, CommonWrapper } from '../../../internal/CommonWrapper';
import { InputLayoutAside } from './InputLayoutAside';
import { InputLayoutContext, InputLayoutContextDefault, InputLayoutContextProps } from './InputLayoutContext';
import { stylesLayout } from './InputLayout.styles';
import { CleanCrossIcon } from '../../../internal/CleanCrossIcon/CleanCrossIcon';

type InputLayoutRootFromInputProps = Pick<InputProps, 'leftIcon' | 'rightIcon' | 'prefix' | 'suffix'>;
type InputLayoutRootFromInputProps = Pick<
InputProps,
'showCleanCross' | 'leftIcon' | 'rightIcon' | 'prefix' | 'suffix'
>;

export interface InputLayoutRootProps extends InputLayoutRootFromInputProps, CommonProps {
labelProps: React.LabelHTMLAttributes<HTMLLabelElement>;
context: Partial<InputLayoutContextProps>;
clearInput: () => void;
}

export const InputLayout = forwardRefAndName<HTMLLabelElement, InputLayoutRootProps>('InputLayout', (props, ref) => {
const { leftIcon, rightIcon, prefix, suffix, labelProps, context, children } = props;
const { showCleanCross, clearInput, leftIcon, rightIcon, prefix, suffix, labelProps, context, children } = props;
const _context: InputLayoutContextProps = { ...InputLayoutContextDefault, ...context };

const cleanCrossIcon = showCleanCross ? <CleanCrossIcon size={_context.size} onClick={clearInput} /> : undefined;

return (
<InputLayoutContext.Provider value={_context}>
<CommonWrapper {...props}>
<label ref={ref} data-tid={InputDataTids.root} {...labelProps}>
<InputLayoutAside icon={leftIcon} text={prefix} side="left" />
<span className={stylesLayout.input()}>{children}</span>
<InputLayoutAside icon={rightIcon} text={suffix} side="right" />
{showCleanCross && !rightIcon /*&& context.focused*/ ? (
<InputLayoutAside icon={cleanCrossIcon} text={suffix} side="right" />
) : (
<InputLayoutAside icon={rightIcon} text={suffix} side="right" />
)}
</label>
</CommonWrapper>
</InputLayoutContext.Provider>
Expand Down
13 changes: 13 additions & 0 deletions packages/react-ui/components/Input/__stories__/Input.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -457,3 +457,16 @@ export const WithMaskAndSelectAllProp: Story = () => {
export const SearchTypeApi: Story = () => <Input defaultValue="Some value" type="search" selectAllOnFocus />;

export const InputTypeApi: Story = () => <Input defaultValue={123} type="number" selectAllOnFocus />;

export const AAAAAAAAAAAA: Story = () => {
const [value, setValue] = React.useState('Через value, управляемый контрол');
return (
<>
<Input showCleanCross value={value} onValueChange={setValue} />

<br />

<Input showCleanCross placeholder={'Неуправляемый контрол'} />
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { css, memoizeStyle } from '../../lib/theming/Emotion';
import { Theme } from '../../lib/theming/Theme';
import { resetButton } from '../../lib/styles/Mixins';

export const styles = memoizeStyle({
root(t: Theme) {
return css`
${resetButton()}
display: inline-block;
position: relative;
border-radius: ${t.closeBtnIconBorderRadius};
color: ${t.closeBtnIconColor};
cursor: pointer;
transition: color ${t.transitionDuration} ${t.transitionTimingFunction};
background-color: #f8ec58;

&:enabled:hover {
color: ${t.closeBtnIconHoverColor};
}
&:enabled:not hover {
color: ${t.closeBtnIconColor};
}
`;
},
rootDisabled(t: Theme) {
return css`
color: ${t.closeBtnIconDisabledColor};
`;
},
focus(t: Theme) {
return css`
color: ${t.closeBtnIconHoverColor};
`;
},
wrapper() {
return css`
box-sizing: content-box;
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
pointer-events: none;
`;
},

cleanCrossSmall(t: Theme) {
return css`
width: ${t.cleanCrossIconWidthSmall};
height: ${t.cleanCrossIconHeightSmall};
margin-right: ${t.cleanCrossIconRightMarginSmall};
`;
},
cleanCrossMedium(t: Theme) {
return css`
width: ${t.cleanCrossIconWidthMedium};
height: ${t.cleanCrossIconHeightMedium};
margin-right: ${t.cleanCrossIconRightMarginMedium};
`;
},
cleanCrossLarge(t: Theme) {
return css`
width: ${t.cleanCrossIconWidthLarge};
height: ${t.cleanCrossIconHeightLarge};
margin-right: ${t.cleanCrossIconRightMarginLarge};
`;
},
});
84 changes: 84 additions & 0 deletions packages/react-ui/internal/CleanCrossIcon/CleanCrossIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React, { AriaAttributes } from 'react';
import { globalObject } from '@skbkontur/global-object';

import { cx } from '../../lib/theming/Emotion';
import { keyListener } from '../../lib/events/keyListener';
import { ThemeContext } from '../../lib/theming/ThemeContext';
import { CommonWrapper, CommonProps } from '../CommonWrapper';

import { styles } from './CleanCrossIcon.styles';
import { CrossIcon } from './CrossIcon';
import { SizeProp } from '../../lib/types/props';
import { TokenSize } from '../../components/Token';

export interface CleanCrossIconProps
extends Pick<AriaAttributes, 'aria-label'>,
React.ButtonHTMLAttributes<HTMLButtonElement>,
CommonProps {
/** Ширина и высота иконки крестика
* @default small */
size?: SizeProp;

/** Возможность сфокусироваться на кнопке клавишей TAB
* @default true */
tabbable?: boolean;
}

export const CleanCrossIcon: React.FunctionComponent<CleanCrossIconProps> = ({
size = 'small',
tabbable = true,
style,
...rest
}) => {
const theme = React.useContext(ThemeContext);
const getSizeClassName = (size: TokenSize) => {
switch (size) {
case 'large':
return styles.cleanCrossLarge(theme);
case 'medium':
return styles.cleanCrossMedium(theme);
case 'small':
default:
return styles.cleanCrossSmall(theme);
}
};

const [focusedByTab, setFocusedByTab] = React.useState(false);

const handleFocus = () => {
// focus event fires before keyDown eventlistener so we should check tabPressed in async way
globalObject.requestAnimationFrame?.(() => {
if (keyListener.isTabPressed) {
setFocusedByTab(true);
}
});
};
const handleBlur = () => setFocusedByTab(false);

const tabIndex = !tabbable || rest.disabled ? -1 : 0;

return (
<CommonWrapper {...rest}>
<button
tabIndex={tabIndex}
className={cx(
styles.root(theme),
!rest.disabled && focusedByTab && styles.focus(theme),
rest.disabled && styles.rootDisabled(theme),
getSizeClassName(size),
)}
style={{ ...style }}
onFocus={handleFocus}
onBlur={handleBlur}
{...rest}
>
<span className={styles.wrapper()}>
<CrossIcon size={size} focusable={tabIndex >= 0} />
</span>
</button>
</CommonWrapper>
);
};

CleanCrossIcon.__KONTUR_REACT_UI__ = 'CleanCrossIcon';
CleanCrossIcon.displayName = 'CleanCrossIcon';
13 changes: 13 additions & 0 deletions packages/react-ui/internal/CleanCrossIcon/CrossIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';

import { iconSizer } from '../icons2022/iconSizer';
import { XCircleIcon16Solid, XCircleIcon20Solid, XCircleIcon24Solid } from '@skbkontur/icons/icons/XCircleIcon';

export const CrossIcon = iconSizer(
{
small: () => <XCircleIcon16Solid align="none" />,
medium: () => <XCircleIcon20Solid align="none" />,
large: () => <XCircleIcon24Solid align="none" />,
},
'CrossIcon',
);
2 changes: 1 addition & 1 deletion packages/react-ui/internal/InputLikeText/InputLikeText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export class InputLikeText extends React.Component<InputLikeTextProps, InputLike

private getProps = createPropsGetter(InputLikeText.defaultProps);

public state = { blinking: false, focused: false };
public state = { blinking: false, focused: false, value: this.props.value ?? '' };

private theme!: Theme;
private node: HTMLElement | null = null;
Expand Down
32 changes: 32 additions & 0 deletions packages/react-ui/internal/themes/BasicLightTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2428,6 +2428,38 @@ export class BasicLightThemeInternal {
'0px 0px 0px 3px rgb(149, 149, 149), 0px 0px 0px 8px rgba(61, 61, 61, 0.2)';
//#endregion FileUploader

//#region CleanCrossIcon
public static get cleanCrossIconWidthSmall() {
return this.inputHeightSmall;
}
public static get cleanCrossIconWidthMedium() {
return this.inputHeightMedium;
}
public static get cleanCrossIconWidthLarge() {
return this.inputHeightLarge;
}
public static get cleanCrossIconHeightSmall() {
return this.inputHeightSmall;
}
public static get cleanCrossIconHeightMedium() {
return this.inputHeightMedium;
}
public static get cleanCrossIconHeightLarge() {
return this.inputHeightLarge;
}

public static get cleanCrossIconRightMarginSmall() {
return -parseInt(this.inputPaddingXSmall) + 'px';
}
public static get cleanCrossIconRightMarginMedium() {
return -parseInt(this.inputPaddingXMedium) + 'px';
}
public static get cleanCrossIconRightMarginLarge() {
return -parseInt(this.inputPaddingXLarge) + 'px';
}

//#endregion CleanCrossIcon

//#region CloseIcon
public static closeBtnIconColor = 'rgba(0, 0, 0, 0.32)';
public static closeBtnIconDisabledColor = '#8b8b8b';
Expand Down