Skip to content

Commit

Permalink
Merge pull request #123 from aversini/feat(TextArea)-component-is-now…
Browse files Browse the repository at this point in the history
…-hybrid---controlled-and-uncontrolled

feat(TextArea): component is now hybrid - controlled and uncontrolled
  • Loading branch information
aversini authored Dec 6, 2023
2 parents b64c237 + aa3a262 commit 35016b0
Show file tree
Hide file tree
Showing 3 changed files with 181 additions and 29 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { act, renderHook } from "@testing-library/react";

import { useUncontrolled } from "../useUncontrolled";

describe("use-uncontrolled", () => {
it("returns default value for initial uncontrolled state", () => {
const hook = renderHook(() =>
useUncontrolled({
value: undefined,
defaultValue: "test-default",
finalValue: "test-final",
}),
).result.current;
expect(hook[0]).toBe("test-default");
});

it("returns final value for initial uncontrolled state if default value was not provided", () => {
const hook = renderHook(() =>
useUncontrolled({
value: undefined,
defaultValue: undefined,
finalValue: "test-final",
}),
).result.current;
expect(hook[0]).toBe("test-final");
});

it("supports uncontrolled state", () => {
const view = renderHook(() =>
useUncontrolled({ defaultValue: "default-value" }),
);
act(() => view.result.current[1]("change-value"));
expect(view.result.current[0]).toBe("change-value");
});

it("calls onChange with uncontrolled state", () => {
const spy = vi.fn();
const view = renderHook(() =>
useUncontrolled({ defaultValue: "default-value", onChange: spy }),
);
act(() => view.result.current[1]("change-value"));
expect(spy).toHaveBeenCalledWith("change-value");
});

it("supports controlled state", () => {
const spy = vi.fn();
const view = renderHook(() =>
useUncontrolled({ value: "controlled-value", onChange: spy }),
);

act(() => view.result.current[1]("change-value"));
expect(view.result.current[0]).toBe("controlled-value");

expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith("change-value");
});
});
61 changes: 61 additions & 0 deletions packages/ui-components/src/common/hooks/useUncontrolled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* MIT License
*
* Copyright (c) 2021 Vitaly Rtishchev
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/

import { useState } from "react";

interface UseUncontrolledInput<T> {
/** Value for controlled state */
value?: T;

/** Initial value for uncontrolled state */
defaultValue?: T;

/** Final value for uncontrolled state when value and defaultValue are not provided */
finalValue?: T;

/** Controlled state onChange handler */
onChange?: (value: T) => void;
}

export function useUncontrolled<T>({
value,
defaultValue,
finalValue,
onChange = () => {},
}: UseUncontrolledInput<T>): [T, (value: T) => void, boolean] {
const [uncontrolledValue, setUncontrolledValue] = useState(
defaultValue !== undefined ? defaultValue : finalValue,
);

const handleUncontrolledChange = (val: T) => {
setUncontrolledValue(val);
onChange?.(val);
};

if (value !== undefined) {
return [value as T, onChange, true];
}

return [uncontrolledValue as T, handleUncontrolledChange, false];
}
92 changes: 63 additions & 29 deletions packages/ui-components/src/components/TextArea/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useLayoutEffect, useRef, useState } from "react";

import { TEXT_AREA_CLASSNAME } from "../../common/constants";
import { useUncontrolled } from "../../common/hooks/useUncontrolled";
import useUniqueId from "../../common/hooks/useUniqueId";
import { mergeRefs } from "../../common/utilities";
import { LiveRegion } from "../private/LiveRegion/LiveRegion";
Expand All @@ -20,6 +21,8 @@ export const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
focusKind = "light",
borderKind = "dark",
errorKind = "light",
value,
defaultValue,

disabled = false,
noBorder = false,
Expand Down Expand Up @@ -47,7 +50,6 @@ export const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
const textAreaId = useUniqueId({ id, prefix: `${TEXT_AREA_CLASSNAME}-` });

const [textAreaPaddingRight, setTextAreaPaddingRight] = useState(0);
const [userInput, setUserTextArea] = useState("");

const liveErrorMessage = `${name} error, ${helperText}`;
const textTextAreaClassName = getTextAreaClasses({
Expand All @@ -62,11 +64,32 @@ export const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
errorKind,
});

/**
* useUncontrolled hook is used to make the textarea
* both controlled and uncontrolled.
*/
const [userInput, setValue] = useUncontrolled({
value,
defaultValue,
onChange: (value: any) => {
const e: any = {
target: {
value,
},
};
onChange && onChange(e);
},
});

const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setUserTextArea(e.target.value);
onChange && onChange(e);
setValue(e.target.value);
};

/**
* This effect is used to add padding to the rightElement so
* that the text in the textarea does not overlap with the
* rightElement.
*/
useLayoutEffect(() => {
if (!raw && rightElementRef.current) {
setTextAreaPaddingRight(rightElementRef.current.offsetWidth + 18 + 10);
Expand All @@ -82,21 +105,49 @@ export const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
if (raw) {
return;
}

if (textAreaRef && textAreaRef.current) {
textAreaRef.current.style.height = "inherit";
// Set the height to match the content
textAreaRef.current.style.height =
textAreaRef.current.scrollHeight + "px";
}
}, [userInput, raw]);

/**
* This section is to toggle the transitions.
* This is to prevent the label and helper text from
* animating when the user is typing. The animation is
* re-enabled when there is nothing in the textarea.
*
* The reason for the timeout is to prevent it to be
* re-enabled too soon when the user clears out the
* whole textarea.
*/
useLayoutEffect(() => {
if (raw) {
return;
}
setTimeout(() => {
labelRef?.current?.style.setProperty(
"--av-text-area-wrapper-transition",
!userInput ? "all 0.2s ease-out" : "none",
);
}, 0);
}, [userInput, raw]);

/**
* If the height of the textarea has changed, we
* need to adjust the label and helper text to match
* the new height.
* This is done by calculating the difference in
* height and then adjusting the label and helper
* text by that amount.
*/
/**
* This effect is used to adjust the label and helper text
* when the height of the textarea changes.
* This is done by calculating the difference in
* height and then adjusting the label and helper
* text by that amount.
*/
useLayoutEffect(() => {
if (raw) {
return;
}

if (textAreaRef && textAreaRef.current) {
const { labelOffset, helperTextOffset, scrollHeight } =
adjustLabelAndHelperText({
scrollHeight: textAreaRef.current.scrollHeight,
Expand Down Expand Up @@ -125,23 +176,6 @@ export const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(

textAreaHeightRef.current = scrollHeight || textAreaHeightRef.current;
}

/**
* This section is to toggle the transitions.
* This is to prevent the label and helper text from
* animating when the user is typing. The animation is
* re-enabled when there is nothing in the textarea.
*
* The reason for the timeout is to prevent it to be
* re-enabled too soon when the user clears out the
* whole textarea.
*/
setTimeout(() => {
labelRef?.current?.style.setProperty(
"--av-text-area-wrapper-transition",
!userInput ? "all 0.2s ease-out" : "none",
);
}, 0);
}, [userInput, raw]);

return (
Expand Down

0 comments on commit 35016b0

Please sign in to comment.