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:
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user