Skip to content

Commit

Permalink
feat: add TextareaAutosize component and multiline support for `I…
Browse files Browse the repository at this point in the history
…nputBase`
  • Loading branch information
juanrgm committed Mar 8, 2023
1 parent fe8ad05 commit 5a017a0
Show file tree
Hide file tree
Showing 6 changed files with 267 additions and 11 deletions.
6 changes: 6 additions & 0 deletions .changeset/slow-trees-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@suid/material": minor
"@suid/base": minor
---

Add `TextareaAutosize` component and `multiline` support for `InputBase`
215 changes: 215 additions & 0 deletions packages/base/src/TextareaAutosize/TextareaAutosize.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { TextareaAutosizeTypeMap } from ".";
import createComponentFactory from "./../createComponentFactory";
import createEffectWithCleaning from "@suid/system/createEffectWithCleaning";
import createRef from "@suid/system/createRef";
import { debounce, unstable_ownerWindow as ownerWindow } from "@suid/utils";
import {
createSignal,
createEffect,
on,
splitProps,
mergeProps,
JSX,
} from "solid-js";

const $ = createComponentFactory<TextareaAutosizeTypeMap>()({
name: "MuiTextareaAutosize",
selfPropNames: ["ref", "maxRows", "minRows"],
});

function getStyleValue(
computedStyle: CSSStyleDeclaration,
property: keyof CSSStyleDeclaration
) {
return parseInt(computedStyle[property] as string, 10) || 0;
}

const styles = {
shadow: {
// Visibility needed to hide the extra text area on iPads
visibility: "hidden",
// Remove from the content flow
position: "absolute",
// Ignore the scrollbar width
overflow: "hidden",
height: 0,
top: 0,
left: 0,
// Create a new layer, increase the isolation of the computed values
transform: "translateZ(0)",
} as JSX.CSSProperties,
};

const TextareaAutosize = $.defineComponent(function (props) {
const ref = createRef<HTMLTextAreaElement>(props);

const [, other] = splitProps(props, ["maxRows", "minRows", "style", "value"]);

const baseProps = mergeProps({ minRows: 1 }, props);

let shadowRef!: HTMLTextAreaElement;
const renders = { current: 0 };
const [state, setState] = createSignal<{
overflow?: boolean;
outerHeightStyle?: number;
}>({});

const syncHeight = () => {
const input = ref.current;
const containerWindow = ownerWindow(input);
const computedStyle = containerWindow.getComputedStyle(input);

// If input's width is shrunk and it's not visible, don't sync height.
if (computedStyle.width === "0px") {
return;
}

shadowRef.style.width = computedStyle.width;
shadowRef.value = input.value || props.placeholder || "x";

if (shadowRef.value.slice(-1) === "\n") {
// Certain fonts which overflow the line height will cause the textarea
// to report a different scrollHeight depending on whether the last line
// is empty. Make it non-empty to avoid this issue.
shadowRef.value += " ";
}

const boxSizing = computedStyle["boxSizing"];
const padding =
getStyleValue(computedStyle, "paddingBottom") +
getStyleValue(computedStyle, "paddingTop");
const border =
getStyleValue(computedStyle, "borderBottomWidth") +
getStyleValue(computedStyle, "borderTopWidth");

// The height of the inner content
const innerHeight = shadowRef.scrollHeight;

// Measure height of a textarea with a single row
shadowRef.value = "x";
const singleRowHeight = shadowRef.scrollHeight;

// The height of the outer content
let outerHeight = innerHeight;

if (baseProps.minRows) {
outerHeight = Math.max(
Number(baseProps.minRows) * singleRowHeight,
outerHeight
);
}
if (props.maxRows) {
outerHeight = Math.min(
Number(props.maxRows) * singleRowHeight,
outerHeight
);
}
outerHeight = Math.max(outerHeight, singleRowHeight);

// Take the box sizing into account for applying this value as a style.
const outerHeightStyle =
outerHeight + (boxSizing === "border-box" ? padding + border : 0);
const overflow = Math.abs(outerHeight - innerHeight) <= 1;

setState((prevState) => {
// Need a large enough difference to update the height.
// This prevents infinite rendering loop.
if (
renders.current < 20 &&
((outerHeightStyle > 0 &&
Math.abs((prevState.outerHeightStyle || 0) - outerHeightStyle) > 1) ||
prevState.overflow !== overflow)
) {
renders.current += 1;
return {
overflow,
outerHeightStyle,
};
}

if (process.env.NODE_ENV !== "production") {
if (renders.current === 20) {
console.error(
[
"MUI: Too many re-renders. The layout is unstable.",
"TextareaAutosize limits the number of renders to prevent an infinite loop.",
].join("\n")
);
}
}

return prevState;
});
};

createEffectWithCleaning(() => {
syncHeight();
const handleResize = debounce(() => {
renders.current = 0;
syncHeight();
});
const containerWindow = ownerWindow(ref.current);
containerWindow.addEventListener("resize", handleResize);
let resizeObserver: ResizeObserver | undefined;

if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(ref.current);
}
return () => {
handleResize.clear();
containerWindow.removeEventListener("resize", handleResize);
if (resizeObserver) {
resizeObserver.disconnect();
}
};
});

createEffect(
on(
() => [props.value],
() => {
renders.current = 0;
syncHeight();
}
)
);

const style1 = mergeProps(
{
get height() {
return `${state().outerHeightStyle}px`;
},
get overflow() {
return state().overflow ? "hidden" : null;
},
},
() => props.style
) as JSX.CSSProperties;

const style2 = mergeProps(styles.shadow, () => props.style, {
padding: 0,
}) as JSX.CSSProperties;

return (
<>
<textarea
ref={ref}
// Apply the rows prop to get a "correct" first SSR paint
rows={baseProps.minRows}
style={style1}
{...other}
/>
<textarea
aria-hidden
class={props.class}
readOnly
ref={shadowRef}
tabIndex={-1}
style={style2}
/>
</>
);
}, false);

export default TextareaAutosize;
34 changes: 34 additions & 0 deletions packages/base/src/TextareaAutosize/TextareaAutosizeProps.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as ST from "@suid/types";

export type TextareaAutosizeTypeMap<
P = {},
D extends ST.ElementType = "div"
> = {
name: "MuiTextareaAutosize";
defaultPropNames: "minRows";
selfProps: {
ref?: ST.Ref<HTMLTextAreaElement>;

/**
* Maximum number of rows to display.
*/
maxRows?: string | number;

/**
* Minimum number of rows to display.
* @default 1
*/
minRows?: string | number;
};
props: P &
TextareaAutosizeTypeMap["selfProps"] &
Omit<ST.PropsOf<"textarea">, "children" | "rows">;
defaultComponent: D;
};

export type TextareaAutosizeProps<
D extends ST.ElementType = TextareaAutosizeTypeMap["defaultComponent"],
P = {}
> = ST.OverrideProps<TextareaAutosizeTypeMap<P, D>, D>;

export default TextareaAutosizeProps;
4 changes: 4 additions & 0 deletions packages/base/src/TextareaAutosize/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { default } from "./TextareaAutosize";
export * from "./TextareaAutosize";

export * from "./TextareaAutosizeProps";
5 changes: 3 additions & 2 deletions packages/base/src/createComponentFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,12 @@ function createComponentFactory<
}

function defineComponent(
cb: (props: Props) => JSXElement
cb: (props: Props) => JSXElement,
styled = true
): C extends OverridableTypeMap ? OverridableComponent<C> : SuidElement<C> {
cb = componentTrap(cb) as any;
cb.toString = () => `.${options.name}-root`;
(cb as any).__styled = true;
if (styled) (cb as any).__styled = true;
return cb as any;
}

Expand Down
14 changes: 5 additions & 9 deletions packages/material/src/InputBase/InputBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import useControlled from "../utils/useControlled";
import { InputBaseTypeMap } from "./InputBaseProps";
import inputBaseClasses, { getInputBaseUtilityClass } from "./inputBaseClasses";
import { isFilled } from "./utils";
import TextareaAutosize from "@suid/base/TextareaAutosize";
import createComponentFactory from "@suid/base/createComponentFactory";
import isHostComponent from "@suid/base/utils/isHostComponent";
import Dynamic from "@suid/system/Dynamic";
Expand Down Expand Up @@ -374,7 +375,8 @@ const InputBase = $.component(function InputBase({
} else if (typeof v === "string") {
const inputElement = inputRef.ref as HTMLInputElement;
const type = inputElement.type ?? "text";
const isSelectionType = selectionTypes.has(type);
const isSelectionType =
inputElement.nodeName === "TEXTAREA" || selectionTypes.has(type);
const selectionStart = lastSelectionStart ?? v.length;
if (v !== inputRef.ref.value) {
inputRef.ref.value = v;
Expand Down Expand Up @@ -452,14 +454,8 @@ const InputBase = $.component(function InputBase({

const isMultilineInput = () =>
props.multiline && props.inputComponent === "input";
const InputComponent = () => {
const InputComponent = props.inputComponent;
if (isMultilineInput()) {
// [review]
//InputComponent = TextareaAutosize;
}
return InputComponent;
};
const InputComponent = () =>
isMultilineInput() ? TextareaAutosize : props.inputComponent;

const inputProps = createMemo(() => {
let inputProps = props.inputProps;
Expand Down

0 comments on commit 5a017a0

Please sign in to comment.