Merge pull request 'feat: ui/auto-resizing-textarea' (#4562) from ui/auto-resizing-textarea into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4562
This commit is contained in:
brianmcgee
2025-08-01 09:40:33 +00:00
5 changed files with 155 additions and 21 deletions

View File

@@ -114,3 +114,30 @@ export const ReadOnly: Story = {
"Good evening. I'm Ron Burgundy, and this is what's happening in your world tonight. ", "Good evening. I'm Ron Burgundy, and this is what's happening in your world tonight. ",
}, },
}; };
export const AutoResize: Story = {
args: {
label: "Auto-resizing TextArea",
description:
"This textarea automatically adjusts its height based on content",
tooltip: "Try typing multiple lines to see it grow",
input: {
placeholder: "Start typing to see the textarea grow...",
autoResize: true,
minRows: 2,
maxRows: 10,
},
},
};
export const AutoResizeNoMax: Story = {
args: {
label: "Auto-resize without max height",
description: "This textarea grows indefinitely with content",
input: {
placeholder: "This will grow as much as needed...",
autoResize: true,
minRows: 3,
},
},
};

View File

@@ -7,6 +7,7 @@ import {
import cx from "classnames"; import cx from "classnames";
import { Label } from "./Label"; import { Label } from "./Label";
import { PolymorphicProps } from "@kobalte/core/polymorphic"; import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { createEffect, createSignal, splitProps } from "solid-js";
import "./TextInput.css"; import "./TextInput.css";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
@@ -14,11 +15,86 @@ import { Orienter } from "./Orienter";
export type TextAreaProps = FieldProps & export type TextAreaProps = FieldProps &
TextFieldRootProps & { TextFieldRootProps & {
input?: PolymorphicProps<"textarea", TextFieldTextAreaProps<"input">>; input: PolymorphicProps<"textarea", TextFieldTextAreaProps<"input">> & {
autoResize?: boolean;
minRows?: number;
maxRows?: number;
};
}; };
export const TextArea = (props: TextAreaProps) => ( export const TextArea = (props: TextAreaProps) => {
let textareaRef: HTMLTextAreaElement;
const [lineHeight, setLineHeight] = createSignal(0);
const autoResize = () => {
const input = props.input;
if (!(textareaRef && input.autoResize && lineHeight() > 0)) return;
// Reset height to auto to get accurate scrollHeight
textareaRef.style.height = "auto";
// Calculate min and max heights based on rows
const minHeight = (input.minRows || 1) * lineHeight();
const maxHeight = input.maxRows ? input.maxRows * lineHeight() : Infinity;
// Set the height based on content, respecting min/max
const newHeight = Math.min(
Math.max(textareaRef.scrollHeight, minHeight),
maxHeight,
);
// Update the height
textareaRef.style.height = `${newHeight}px`;
textareaRef.style.maxHeight = `${maxHeight}px`;
console.log("min/max height", minHeight, maxHeight);
console.log("textarea ref style", textareaRef.style);
};
// Set up auto-resize effect
createEffect(() => {
if (textareaRef && props.input.autoResize) {
// Get computed line height
const computedStyle = window.getComputedStyle(textareaRef);
const computedLineHeight = parseFloat(computedStyle.lineHeight);
if (!isNaN(computedLineHeight)) {
setLineHeight(computedLineHeight);
}
// Initial resize
autoResize();
}
});
// Watch for value changes to trigger resize
createEffect(() => {
if (props.input.autoResize && textareaRef) {
// Access the value to create a dependency
const _ = props.value || props.defaultValue || "";
// Trigger resize on the next tick to ensure DOM is updated
setTimeout(autoResize, 0);
}
});
const input = props.input;
// TextField.Textarea already has an `autoResize` prop
// We filter our props out to avoid conflicting behaviour
const [_, textareaProps] = splitProps(input, [
"autoResize",
"minRows",
"maxRows",
]);
return (
<TextField <TextField
ref={(el: HTMLDivElement) => {
// for some reason capturing the ref directly on TextField.TextArea works in Chrome
// but not in webkit, so we capture the parent ref and query for the textarea
textareaRef = el.querySelector("textarea")! as HTMLTextAreaElement;
}}
class={cx("form-field", "textarea", props.size, props.orientation, { class={cx("form-field", "textarea", props.size, props.orientation, {
inverted: props.inverted, inverted: props.inverted,
ghost: props.ghost, ghost: props.ghost,
@@ -31,7 +107,27 @@ export const TextArea = (props: TextAreaProps) => (
descriptionComponent={TextField.Description} descriptionComponent={TextField.Description}
{...props} {...props}
/> />
<TextField.TextArea {...props.input} /> <TextField.TextArea
class={cx(input.class, {
"auto-resize": input.autoResize,
})}
onInput={(e) => {
autoResize();
if (!input.onInput) {
return;
}
// Call original onInput if it exists
if (typeof input.onInput === "function") {
input.onInput(e);
} else if (Array.isArray(input.onInput)) {
input.onInput.forEach((handler) => handler(e));
}
}}
{...textareaProps}
/>
</Orienter> </Orienter>
</TextField> </TextField>
); );
};

View File

@@ -42,6 +42,11 @@ div.form-field {
&[data-readonly] { &[data-readonly] {
@apply overflow-y-hidden; @apply overflow-y-hidden;
} }
&.auto-resize {
@apply resize-none overflow-y-auto;
transition: height 0.1s ease-out;
}
} }
&.horizontal { &.horizontal {

View File

@@ -10,6 +10,6 @@ div.sidebar-section {
} }
& > div.content { & > div.content {
@apply w-full h-full px-1.5 py-3 rounded-md bg-inv-4; @apply w-full h-fit px-1.5 py-3 rounded-md bg-inv-4;
} }
} }

View File

@@ -116,7 +116,13 @@ export const SectionGeneral = () => {
inverted inverted
readOnly={!editing} readOnly={!editing}
orientation="horizontal" orientation="horizontal"
input={{ ...input, rows: 4, placeholder: "No description" }} input={{
...input,
autoResize: true,
minRows: 2,
maxRows: 4,
placeholder: "No description",
}}
/> />
)} )}
</Field> </Field>