Skip to content
Open
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
34 changes: 34 additions & 0 deletions apps/www/src/content/docs/components/textarea/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ export const playground = {
type: 'checkbox',
defaultValue: false
},
size: {
type: 'select',
options: ['large', 'small'],
defaultValue: 'large'
},
variant: {
type: 'select',
options: ['default', 'borderless'],
defaultValue: 'default'
},
width: {
type: 'text',
defaultValue: '400px'
Expand Down Expand Up @@ -55,6 +65,30 @@ export const controlledDemo = {
}`
};

export const sizeDemo = {
type: 'code',
code: `<Flex direction="column" gap="medium" style={{ width: 400 }}>
<TextArea placeholder="Large size (default)" />
<TextArea placeholder="Small size" size="small" />
</Flex>`
};

export const variantDemo = {
type: 'code',
code: `<Flex direction="column" gap="medium" style={{ width: 400 }}>
<TextArea placeholder="Default variant" />
<TextArea placeholder="Borderless variant" variant="borderless" />
</Flex>`
};

export const rowsDemo = {
type: 'code',
code: `<Flex direction="column" gap="medium" style={{ width: 400 }}>
<TextArea placeholder="Default (3 rows)" />
<TextArea placeholder="6 rows" rows={6} />
</Flex>`
};

export const widthDemo = {
type: 'code',
code: `<TextArea
Expand Down
24 changes: 23 additions & 1 deletion apps/www/src/content/docs/components/textarea/index.mdx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
---
title: TextArea
description: A multi-line text input field.
description: A multi-line text input field with size and variant options.
source: packages/raystack/components/text-area
---

import {
playground,
basicDemo,
controlledDemo,
sizeDemo,
variantDemo,
rowsDemo,
widthDemo,
withFieldDemo,
} from "./demo.ts";
Expand Down Expand Up @@ -60,6 +63,24 @@ Example of TextArea in controlled mode.

<Demo data={controlledDemo} />

### Size Variants

TextArea comes in two sizes: `large` (default) and `small`.

<Demo data={sizeDemo} />

### Visual Variants

TextArea supports `default` and `borderless` visual variants.

<Demo data={variantDemo} />

### Custom Rows

TextArea defaults to 3 visible rows. Use the `rows` prop to adjust.

<Demo data={rowsDemo} />

### Custom Width

TextArea with custom width specification.
Expand All @@ -71,3 +92,4 @@ TextArea with custom width specification.
- Use with [Field](/docs/components/field) for automatic label association and error linking
- Required state is communicated via `aria-required`
- Invalid state is communicated via `aria-invalid`
- Content is scrollable when text exceeds the visible rows
18 changes: 18 additions & 0 deletions apps/www/src/content/docs/components/textarea/props.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
export interface TextAreaProps {
/**
* Size variant of the textarea.
* @defaultValue "large"
*/
size?: 'small' | 'large';

/**
* Visual variant of the textarea.
* @defaultValue "default"
*/
variant?: 'default' | 'borderless';

/** Whether the textarea is disabled. */
disabled?: boolean;

Expand All @@ -11,6 +23,12 @@ export interface TextAreaProps {
*/
width?: string | number;

/**
* Number of visible text rows.
* @defaultValue 3
*/
rows?: number;

/** Controlled value for the textarea. */
value?: string;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,48 @@ describe('TextArea', () => {
});
});

describe('Rows', () => {
it('defaults to 3 rows', () => {
render(<TextArea />);
const textarea = screen.getByRole('textbox');
expect(textarea).toHaveAttribute('rows', '3');
});

it('allows overriding rows', () => {
render(<TextArea rows={6} />);
const textarea = screen.getByRole('textbox');
expect(textarea).toHaveAttribute('rows', '6');
});
});

describe('Sizes', () => {
it('renders large size by default', () => {
render(<TextArea />);
const textarea = screen.getByRole('textbox');
expect(textarea).toHaveClass(styles['size-large']);
});

it('renders small size when specified', () => {
render(<TextArea size='small' />);
const textarea = screen.getByRole('textbox');
expect(textarea).toHaveClass(styles['size-small']);
});
});

describe('Variants', () => {
it('renders default variant by default', () => {
render(<TextArea />);
const textarea = screen.getByRole('textbox');
expect(textarea).toHaveClass(styles['variant-default']);
});

it('renders borderless variant when specified', () => {
render(<TextArea variant='borderless' />);
const textarea = screen.getByRole('textbox');
expect(textarea).toHaveClass(styles['variant-borderless']);
});
});

describe('Accessibility', () => {
it('supports aria-label', () => {
render(<TextArea aria-label='Message input' />);
Expand Down
34 changes: 32 additions & 2 deletions packages/raystack/components/text-area/text-area.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@
outline: none;
height: auto;
width: 100%;
min-height: var(--rs-space-13);
background-color: var(--rs-color-background-base-primary);
border: 0.5px solid var(--rs-color-border-base-tertiary);
border-radius: var(--rs-radius-2);
font-size: var(--rs-font-size-small);
line-height: var(--rs-line-height-small);
color: var(--rs-color-foreground-base-primary);
padding: var(--rs-space-3);
overflow: hidden;
overflow: auto;
}

@media (prefers-reduced-motion: no-preference) {
Expand Down Expand Up @@ -61,3 +60,34 @@
.textarea[data-invalid]:focus {
border-color: var(--rs-color-border-danger-emphasis-hover);
}

/* Size variants */
.size-large {
padding: var(--rs-space-3);
font-size: var(--rs-font-size-small);
line-height: var(--rs-line-height-small);
}

.size-small {
padding: var(--rs-space-2);
font-size: var(--rs-font-size-small);
line-height: var(--rs-line-height-small);
}

/* Variant styles */
.variant-default {
border: 0.5px solid var(--rs-color-border-base-tertiary);
}

.variant-borderless {
border-color: transparent;
}

.variant-borderless:hover {
border-color: transparent;
}

.variant-borderless:focus:not(:disabled) {
border-color: transparent;
outline: none;
}
Comment on lines +90 to +93
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Borderless variant loses visible keyboard focus state.

Line 90–93 keeps border transparent and removes outline, so focused and unfocused states become visually indistinguishable for keyboard users. Please retain a visible focus indicator for variant="borderless".

Suggested patch
 .variant-borderless:focus:not(:disabled) {
   border-color: transparent;
-  outline: none;
+  outline: 2px solid var(--rs-color-border-accent-emphasis);
+  outline-offset: 1px;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/raystack/components/text-area/text-area.module.css` around lines 90
- 93, The CSS for .variant-borderless currently removes the border and outline
on focus, making keyboard focus invisible; update the focus rule for
.variant-borderless:focus:not(:disabled) to keep a visible focus indicator
(e.g., use a non-transparent border-color, box-shadow, or outline with
accessible contrast) instead of border-color: transparent and outline: none so
keyboard users can see focus on the TextArea component (class
.variant-borderless / TextArea component).

32 changes: 29 additions & 3 deletions packages/raystack/components/text-area/text-area.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
import { Field as FieldPrimitive } from '@base-ui/react/field';
import { cx } from 'class-variance-authority';
import { cva, cx, type VariantProps } from 'class-variance-authority';
import { ChangeEvent, type ComponentProps } from 'react';
import { useFieldContext } from '../field';

import styles from './text-area.module.css';

export interface TextAreaProps extends ComponentProps<'textarea'> {
const textAreaVariants = cva(styles.textarea, {
variants: {
size: {
small: styles['size-small'],
large: styles['size-large']
},
variant: {
default: styles['variant-default'],
borderless: styles['variant-borderless']
}
},
defaultVariants: {
size: 'large',
variant: 'default'
}
});

export interface TextAreaProps
extends Omit<ComponentProps<'textarea'>, 'size'>,
VariantProps<typeof textAreaVariants> {
disabled?: boolean;
placeholder?: string;
width?: string | number;
Expand All @@ -22,14 +41,21 @@ export function TextArea({
onChange,
placeholder,
required,
size,
variant,
...props
}: TextAreaProps) {
const fieldContext = useFieldContext();
const resolvedRequired = required ?? fieldContext?.required;

const textarea = (
<textarea
className={cx(styles.textarea, disabled && styles.disabled, className)}
rows={3}
className={cx(
textAreaVariants({ size, variant }),
disabled && styles.disabled,
className
)}
value={value}
onChange={onChange}
disabled={disabled}
Expand Down
Loading