Skip to content

Commit

Permalink
feat: add useControlledState hook (#1276)
Browse files Browse the repository at this point in the history
  • Loading branch information
ValeraS authored and amje committed Feb 6, 2024
1 parent 133c6a3 commit 9493062
Show file tree
Hide file tree
Showing 11 changed files with 432 additions and 80 deletions.
22 changes: 4 additions & 18 deletions src/components/controls/TextArea/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';

import {useForkRef, useUniqId} from '../../../hooks';
import {useControlledState, useForkRef, useUniqId} from '../../../hooks';
import {blockNew} from '../../utils/cn';
import {ClearButton, mapTextInputSizeToButtonSize} from '../common';
import {OuterAdditionalContent} from '../common/OuterAdditionalContent/OuterAdditionalContent';
Expand Down Expand Up @@ -69,15 +69,13 @@ export const TextArea = React.forwardRef<HTMLSpanElement, TextAreaProps>(functio
validationState: validationStateProp,
});

const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue ?? '');
const [inputValue, setInputValue] = useControlledState(value, defaultValue ?? '', onUpdate);
const innerControlRef = React.useRef<HTMLTextAreaElement | HTMLInputElement>(null);
const [hasVerticalScrollbar, setHasVerticalScrollbar] = React.useState(false);
const state = getInputControlState(validationState);
const handleRef = useForkRef(props.controlRef, innerControlRef);
const innerId = useUniqId();

const isControlled = value !== undefined;
const inputValue = isControlled ? value : uncontrolledValue;
const isErrorMsgVisible = validationState === 'invalid' && Boolean(errorMessage);
const isClearControlVisible = Boolean(hasClear && !disabled && inputValue);
const id = originalId || innerId;
Expand All @@ -97,16 +95,10 @@ export const TextArea = React.forwardRef<HTMLSpanElement, TextAreaProps>(functio
tabIndex,
name,
onChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
const newValue = event.target.value;
if (!isControlled) {
setUncontrolledValue(newValue);
}
setInputValue(event.target.value);
if (onChange) {
onChange(event);
}
if (onUpdate) {
onUpdate(newValue);
}
},
autoComplete: prepareAutoComplete(autoComplete),
controlProps: {
Expand All @@ -131,15 +123,9 @@ export const TextArea = React.forwardRef<HTMLSpanElement, TextAreaProps>(functio
if (onChange) {
onChange(syntheticEvent);
}

if (onUpdate) {
onUpdate('');
}
}

if (!isControlled) {
setUncontrolledValue('');
}
setInputValue('');
};

React.useEffect(() => {
Expand Down
26 changes: 6 additions & 20 deletions src/components/controls/TextInput/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';

import {TriangleExclamation} from '@gravity-ui/icons';

import {useForkRef, useUniqId} from '../../../hooks';
import {useControlledState, useForkRef, useUniqId} from '../../../hooks';
import {useElementSize} from '../../../hooks/private';
import {Icon} from '../../Icon';
import {Popover} from '../../Popover';
Expand Down Expand Up @@ -97,15 +97,13 @@ export const TextInput = React.forwardRef<HTMLSpanElement, TextInputProps>(funct
validationState: validationStateProp,
});

const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue ?? '');
const [inputValue, setInputValue] = useControlledState(value, defaultValue ?? '', onUpdate);
const innerControlRef = React.useRef<HTMLTextAreaElement | HTMLInputElement>(null);
const handleRef = useForkRef(props.controlRef, innerControlRef);
const labelRef = React.useRef<HTMLLabelElement>(null);
const startContentRef = React.useRef<HTMLDivElement>(null);
const state = getInputControlState(validationState);

const isControlled = value !== undefined;
const inputValue = isControlled ? value : uncontrolledValue;
const isLabelVisible = Boolean(label);
const isErrorMsgVisible =
validationState === 'invalid' && Boolean(errorMessage) && errorPlacement === 'outside';
Expand Down Expand Up @@ -149,24 +147,20 @@ export const TextInput = React.forwardRef<HTMLSpanElement, TextInputProps>(funct
tabIndex,
name,
onChange(event: React.ChangeEvent<HTMLInputElement>) {
const newValue = event.target.value;
if (!isControlled) {
setUncontrolledValue(newValue);
}
setInputValue(event.target.value);

if (onChange) {
onChange(event);
}
if (onUpdate) {
onUpdate(newValue);
}
},
autoComplete: isAutoCompleteOff ? 'off' : prepareAutoComplete(autoComplete),
controlProps,
};

const handleClear = (event: React.MouseEvent<HTMLSpanElement>) => {
const control = innerControlRef.current;
setInputValue('');

const control = innerControlRef.current;
if (control) {
control.focus();

Expand All @@ -179,14 +173,6 @@ export const TextInput = React.forwardRef<HTMLSpanElement, TextInputProps>(funct
if (onChange) {
onChange(syntheticEvent);
}

if (onUpdate) {
onUpdate('');
}
}

if (!isControlled) {
setUncontrolledValue('');
}
};

Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './useActionHandlers';
export * from './useAsyncActionHandler';
export * from './useBodyScrollLock';
export * from './useControlledState';
export * from './useFileInput';
export * from './useFocusWithin';
export * from './useForkRef';
Expand Down
19 changes: 8 additions & 11 deletions src/hooks/private/useCheckbox/useCheckbox.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';

import {useForkRef} from '../..';
import {useControlledState, useForkRef} from '../..';
import type {ControlProps} from '../../../components/types';
import {eventBroker} from '../../../components/utils/event-broker';

Expand All @@ -27,9 +27,12 @@ export function useCheckbox({
disabled,
}: UseCheckboxProps): UseCheckboxResult {
const innerControlRef = React.useRef<HTMLInputElement>(null);
const [checkedState, setCheckedState] = React.useState(defaultChecked ?? false);
const isControlled = typeof checked === 'boolean';
const isChecked = isControlled ? checked : checkedState;
const [isChecked, setCheckedState] = useControlledState(
checked,
defaultChecked ?? false,
onUpdate,
);

const inputChecked = indeterminate ? false : checked;
const inputAriaChecked = indeterminate ? 'mixed' : isChecked;

Expand All @@ -42,17 +45,11 @@ export function useCheckbox({
}, [indeterminate]);

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!isControlled) {
setCheckedState(event.target.checked);
}
setCheckedState(event.target.checked);

if (onChange) {
onChange(event);
}

if (onUpdate) {
onUpdate(event.target.checked);
}
};

const handleClickCapture = React.useCallback(
Expand Down
26 changes: 26 additions & 0 deletions src/hooks/useControlledState/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!--GITHUB_BLOCK-->

# useControlledState

<!--/GITHUB_BLOCK-->

```tsx
import {useControlledState} from '@gravity-ui/uikit';
```

The `useControlledState` hook that simplify work with controlled/uncontrolled state.

## Properties

| Name | Description | Type | Default |
| :----------- | :---------------------------------------- | :---------------: | :-----: |
| value | if `undefined` then value is uncontrolled | `T \| undefined` | |
| defaultValue | Initial value if value is uncontrolled | `T \| undefined` | |
| onUpdate | Callback on value change | `(v: T) => void` | |

## Result

`useControlledState` returns an array with exactly two values:

1. The current state.
2. The set function that lets you update the state to a different value.
1 change: 1 addition & 0 deletions src/hooks/useControlledState/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useControlledState';
Loading

0 comments on commit 9493062

Please sign in to comment.