Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/text-area-auto-resize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@makeplane/propel": minor
---

Add `autoResize` (and optional `maxRows`) to `TextArea` and `TextAreaField` so the control grows with its value.
14 changes: 14 additions & 0 deletions apps/docs/src/demos/text-area-field/auto-resize.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { TextAreaField } from "@makeplane/propel/components/text-area-field";

export default function AutoResizeDemo() {
return (
<TextAreaField
size="lg"
autoResize
maxRows={8}
rows={2}
label="Comment"
placeholder="Type to grow the field…"
/>
);
}
17 changes: 17 additions & 0 deletions apps/docs/src/demos/text-area/auto-resize.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { TextArea, TextAreaGroup } from "@makeplane/propel/components/text-area";

export default function AutoResizeDemo() {
return (
<TextAreaGroup resize="none">
<TextArea
size="lg"
surface="field"
autoResize
maxRows={8}
rows={2}
aria-label="Comment"
placeholder="Type to grow the field…"
/>
</TextAreaGroup>
);
}
11 changes: 11 additions & 0 deletions apps/docs/src/pages/components/text-area-field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import InvalidDemo from "~/demos/text-area-field/invalid.tsx";
import invalidSource from "~/demos/text-area-field/invalid.tsx?raw";
import ResizeDemo from "~/demos/text-area-field/resize.tsx";
import resizeSource from "~/demos/text-area-field/resize.tsx?raw";
import AutoResizeDemo from "~/demos/text-area-field/auto-resize.tsx";
import autoResizeSource from "~/demos/text-area-field/auto-resize.tsx?raw";
import SizesDemo from "~/demos/text-area-field/sizes.tsx";
import sizesSource from "~/demos/text-area-field/sizes.tsx?raw";

Expand Down Expand Up @@ -72,6 +74,15 @@ The `resize` prop controls the native drag handle: `vertical` allows height chan
<ResizeDemo client:visible />
</ComponentExample>

### Auto resize

`autoResize` grows the field with its value (size min-height as the floor) and turns the native
resize handle off. `maxRows` caps the visible lines; further content scrolls.

<ComponentExample code={autoResizeSource}>
<AutoResizeDemo client:visible />
</ComponentExample>

### Invalid

Setting `error` overrides `hint`, marks the field invalid (`aria-invalid`), and recolors the border to danger.
Expand Down
12 changes: 12 additions & 0 deletions apps/docs/src/pages/components/text-area.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import BasicDemo from "~/demos/text-area/basic.tsx";
import basicSource from "~/demos/text-area/basic.tsx?raw";
import ResizeDemo from "~/demos/text-area/resize.tsx";
import resizeSource from "~/demos/text-area/resize.tsx?raw";
import AutoResizeDemo from "~/demos/text-area/auto-resize.tsx";
import autoResizeSource from "~/demos/text-area/auto-resize.tsx?raw";
import SizesDemo from "~/demos/text-area/sizes.tsx";
import sizesSource from "~/demos/text-area/sizes.tsx?raw";
import SurfacesDemo from "~/demos/text-area/surfaces.tsx";
Expand Down Expand Up @@ -85,6 +87,16 @@ axes. The leaf itself is never resizable.
<ResizeDemo client:visible />
</ComponentExample>

### Auto resize

`autoResize` grows the control with its value, using the size min-height as the floor. Pair it with
`TextAreaGroup` `resize="none"` so the native drag handle does not fight the content height.
`maxRows` caps the visible lines; further content scrolls.

<ComponentExample code={autoResizeSource}>
<AutoResizeDemo client:visible />
</ComponentExample>

### With description

<ComponentExample code={withDescriptionSource}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,46 @@ export const Scroll: Story = {
),
};

/**
* `autoResize` grows the field with its value and turns the native resize handle off. `maxRows`
* caps the visible lines; further content scrolls.
*/
export const AutoResize: Story = {
args: {
label: "Comment",
autoResize: true,
maxRows: 6,
rows: 2,
placeholder: "Type to grow the field…",
},
render: (args) => (
<div className="w-80">
<TextAreaField {...args} />
</div>
),
};

/**
* Interaction test: typing additional lines grows a `TextAreaField` past its initial height, and
* the wrapping group stays `resize: none`.
*/
export const AutoResizeGrowsWithContent: Story = {
...AutoResize,
tags: ["!dev", "!autodocs", "!manifest"],
play: async ({ canvas, userEvent }) => {
const textarea = canvas.getByRole<HTMLTextAreaElement>("textbox", { name: "Comment" });
const group = textarea.parentElement;
if (group == null) throw new Error("expected a TextAreaGroup wrapper");
await expect(getComputedStyle(group).resize).toBe("none");
const initial = textarea.getBoundingClientRect().height;
await userEvent.type(
textarea,
"one{Enter}two{Enter}three{Enter}four{Enter}five{Enter}six{Enter}seven",
);
await expect(textarea.getBoundingClientRect().height).toBeGreaterThan(initial);
},
};

/** The error treatment: danger border, danger helper text, and `aria-invalid`. */
export const Invalid: Story = {
args: {
Expand Down
29 changes: 25 additions & 4 deletions packages/propel/src/components/text-area-field/text-area-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,24 @@ export type TextAreaFieldProps = Omit<TextAreaProps, "size" | "surface"> & {
/** Size scale. `lg` | `xl` | `2xl`. */
size: InputSize;
/**
* Native resize handle on the bordered frame.
* Native resize handle on the bordered frame. Forced to `none` when `autoResize` is set, so the
* drag handle does not fight content-driven height.
*
* @default "vertical"
*/
resize?: TextAreaResize;
/**
* Grow the control with its value, using the size min-height as the floor. Disables the native
* resize handle.
*
* @default false
*/
autoResize?: boolean;
/**
* Maximum visible lines when `autoResize` is set. Further lines scroll inside the control.
* Unbounded when omitted.
*/
maxRows?: number;
/** Label text shown above the control. */
label?: string;
/** Marks the field required: adds a `*` asterisk and sets `required`. */
Expand All @@ -36,7 +49,8 @@ export type TextAreaFieldProps = Omit<TextAreaProps, "size" | "surface"> & {

/**
* Multi-line text field built on Base UI `Field`, rendering the control as a `<textarea>`. Vertical
* layout only.
* layout only. `autoResize` grows the control with its value and disables the native resize
* handle.
*/
export function TextAreaField({
size,
Expand All @@ -48,6 +62,7 @@ export function TextAreaField({
error,
disabled,
resize,
autoResize = false,
...controlProps
}: TextAreaFieldProps) {
return (
Expand All @@ -60,8 +75,14 @@ export function TextAreaField({
orientation="vertical"
/>
<FieldControlContent orientation="vertical" size={size}>
<TextAreaGroup resize={resize}>
<TextArea required={required} size={size} surface="field" {...controlProps} />
<TextAreaGroup resize={autoResize ? "none" : resize}>
<TextArea
required={required}
size={size}
surface="field"
autoResize={autoResize}
{...controlProps}
/>
</TextAreaGroup>
<FieldHelperText size={size} hint={hint} error={error} />
</FieldControlContent>
Expand Down
82 changes: 82 additions & 0 deletions packages/propel/src/components/text-area/text-area.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,88 @@ export const ResizeBothOnFrameInteraction: Story = {
},
};

/**
* `autoResize` grows the control with its value. Pair it with `TextAreaGroup` `resize="none"` so
* the native drag handle does not fight content-driven height. `maxRows` caps the visible lines and
* further content scrolls.
*/
export const AutoResize: Story = {
argTypes: { "aria-label": { control: false } },
args: {
autoResize: true,
rows: 2,
"aria-label": "Comment",
placeholder: "Type to grow the field…",
},
render: (args) => (
<TextAreaGroup resize="none">
<TextArea {...args} />
</TextAreaGroup>
),
};

/**
* Interaction test: typing additional lines grows the control past its size min-height, and
* clearing the value returns it to that floor rather than collapsing to a single line. Tagged out
* of the sidebar/docs/manifest while still running under the default `test` tag.
*/
export const AutoResizeGrowsWithContent: Story = {
...AutoResize,
tags: ["!dev", "!autodocs", "!manifest"],
play: async ({ canvas, userEvent }) => {
const textarea = canvas.getByRole<HTMLTextAreaElement>("textbox", { name: "Comment" });
const initial = textarea.getBoundingClientRect().height;
await userEvent.type(
textarea,
"one{Enter}two{Enter}three{Enter}four{Enter}five{Enter}six{Enter}seven",
);
const grown = textarea.getBoundingClientRect().height;
await expect(grown).toBeGreaterThan(initial);
await userEvent.clear(textarea);
await expect(textarea.getBoundingClientRect().height).toBeCloseTo(initial, 0);
},
};

/**
* Interaction test: an empty auto-resizing field stays on the size `min-height` (the same floor as
* a non-auto-resize field), rather than collapsing to a single line.
*/
export const AutoResizeKeepsSizeMinHeight: Story = {
...AutoResize,
tags: ["!dev", "!autodocs", "!manifest"],
play: async ({ canvas }) => {
const textarea = canvas.getByRole<HTMLTextAreaElement>("textbox", { name: "Comment" });
const rootPx = parseFloat(getComputedStyle(textarea.ownerDocument.documentElement).fontSize);
// `lg` field floor: `--textarea-min-height-lg` = 4.75rem (76px at a 16px root).
const minHeight = 4.75 * (Number.isFinite(rootPx) ? rootPx : 16);
await expect(textarea.getBoundingClientRect().height).toBeCloseTo(minHeight, 0);
},
};

/**
* Interaction test: `maxRows` caps the visible height; further lines keep the same box and scroll
* inside it.
*/
export const AutoResizeMaxRowsCapsHeight: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
args: {
autoResize: true,
maxRows: 3,
rows: 1,
surface: "embedded",
"aria-label": "Comment",
},
render: (args) => <TextArea {...args} />,
play: async ({ canvas, userEvent }) => {
const textarea = canvas.getByRole<HTMLTextAreaElement>("textbox", { name: "Comment" });
await userEvent.type(textarea, "a{Enter}b{Enter}c");
const atCap = textarea.getBoundingClientRect().height;
await userEvent.type(textarea, "{Enter}d{Enter}e{Enter}f{Enter}g");
await expect(textarea.getBoundingClientRect().height).toBe(atCap);
await expect(textarea.scrollHeight).toBeGreaterThan(textarea.clientHeight);
},
};

export const FieldComposition: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
args: { rows: 3 },
Expand Down
Loading
Loading