Skip to content

Commit 3a4f967

Browse files
authored
feat(Textarea): Add slots and deprecate extraActions (#2541)
1 parent 59ba4fd commit 3a4f967

File tree

4 files changed

+173
-99
lines changed

4 files changed

+173
-99
lines changed

.changeset/petite-otters-invent.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@frontify/fondue-components": minor
3+
---
4+
5+
feat(Textarea): Add slots and deprecate extraActions
Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,20 @@
11
/* (c) Copyright Frontify Ltd., all rights reserved. */
22

3-
import { IconClipboard, IconNook, IconQuestionMark } from '@frontify/fondue-icons';
3+
import { IconClipboard, IconNook } from '@frontify/fondue-icons';
44
import { type Meta, type StoryObj } from '@storybook/react-vite';
5+
import { type ComponentProps } from 'react';
56
import { action } from 'storybook/actions';
67

7-
import { type ExtraAction, Textarea } from './Textarea';
8-
9-
const ExtraActions: ExtraAction[] = [
10-
{
11-
icon: <IconClipboard size={16} />,
12-
title: 'Save to Clipboard',
13-
callback: () => {
14-
alert('Mock Copied to Clipboard');
15-
},
16-
},
17-
{
18-
icon: <IconQuestionMark size={16} />,
19-
title: 'Help Desk',
20-
callback: () => alert('Here to Help'),
21-
},
22-
];
8+
import { Textarea, TextareaRoot, TextareaSlot } from './Textarea';
239

2410
type Story = StoryObj<typeof meta>;
2511

26-
const meta: Meta<typeof Textarea> = {
12+
const meta: Meta<typeof TextareaRoot> = {
2713
title: 'Components/Textarea',
28-
component: Textarea,
14+
component: TextareaRoot,
15+
subcomponents: {
16+
'Textarea.Slot': TextareaSlot,
17+
},
2918
tags: ['autodocs'],
3019
parameters: {
3120
status: {
@@ -51,36 +40,49 @@ const meta: Meta<typeof Textarea> = {
5140
selectable: true,
5241
value: undefined,
5342
},
43+
render: (args) => {
44+
// Used to get the correct component in the Storybook for the simple cases (`Textarea` instead of `Textarea.Root`)
45+
// More complex cases are using the Story `render` function
46+
const Component = (props: ComponentProps<typeof Textarea>) => <Textarea {...props} />;
47+
Component.displayName = 'Textarea';
48+
return <Component {...args} />;
49+
},
5450
};
5551

5652
export default meta;
5753

58-
export const Default: Story = {
59-
render: (args) => <Textarea {...args} />,
60-
};
54+
export const Default: Story = {};
6155

6256
export const WithDecoratorAndAutosize: Story = {
6357
args: {
6458
autosize: true,
6559
decorator: <IconNook size={16} />,
6660
placeholder: 'Enter some long form text here',
6761
},
68-
render: (args) => <Textarea {...args} />,
6962
};
7063

71-
export const WithExtraActions: Story = {
64+
export const Required: Story = {
7265
args: {
73-
extraActions: ExtraActions,
7466
placeholder: 'Enter some long form text here',
75-
clearable: true,
67+
required: true,
7668
},
77-
render: (args) => <Textarea {...args} />,
7869
};
7970

80-
export const Required: Story = {
71+
export const WithSlots: Story = {
8172
args: {
8273
placeholder: 'Enter some long form text here',
83-
required: true,
74+
autosize: true,
8475
},
85-
render: (args) => <Textarea {...args} />,
76+
render: (args) => (
77+
<Textarea.Root {...args}>
78+
<Textarea.Slot name="left">
79+
<IconNook size={16} />
80+
</Textarea.Slot>
81+
<Textarea.Slot name="right">
82+
<button onClick={() => alert('Action clicked!')} style={{ cursor: 'pointer' }}>
83+
<IconClipboard size={16} />
84+
</button>
85+
</Textarea.Slot>
86+
</Textarea.Root>
87+
),
8688
};

packages/components/src/components/Textarea/Textarea.tsx

Lines changed: 73 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@ import {
1212
type ForwardedRef,
1313
type KeyboardEventHandler,
1414
type ReactElement,
15+
type ReactNode,
1516
type SyntheticEvent,
1617
} from 'react';
1718

1819
import { useSyncRefs } from '#/hooks/useSyncRefs';
20+
import { cn } from '#/utilities/styleUtilities';
1921

2022
import styles from './styles/textarea.module.scss';
2123

24+
/**
25+
* @deprecated Use Textarea.Slot instead for custom actions
26+
*/
2227
export type ExtraAction = {
2328
icon: ReactElement;
2429
title: string;
@@ -32,6 +37,10 @@ type TextareaProps = {
3237
* The id of the textarea
3338
*/
3439
id?: string;
40+
/**
41+
* The place where the textarea slots are placed
42+
*/
43+
children?: ReactNode;
3544
/**
3645
* If `true`, Textarea will have `autoComplete` functionality
3746
*/
@@ -55,6 +64,7 @@ type TextareaProps = {
5564
disabled?: boolean;
5665
/**
5766
* Collection of extra actions the input can preform
67+
* @deprecated Use Textarea.Slot instead for custom actions
5868
*/
5969
extraActions?: ExtraAction[];
6070
/**
@@ -118,11 +128,12 @@ type TextareaProps = {
118128
value?: string;
119129
};
120130

121-
const TextareaComponent = (
131+
export const TextareaRoot = (
122132
{
123133
'data-test-id': dataTestId = 'fondue-textarea',
124134
autocomplete,
125135
autosize,
136+
children,
126137
clearable,
127138
decorator,
128139
defaultValue,
@@ -179,46 +190,47 @@ const TextareaComponent = (
179190
data-disabled={disabled || readOnly}
180191
data-has-decorator={decorator ? true : false}
181192
data-has-tools={hasTools}
182-
data-replicated-value={value}
183193
data-resizable={resizable}
184194
data-status={status}
185195
data-max-rows={!!maxRows}
186196
data-test-id={dataTestId}
187197
style={{ '--max-rows': `${maxRows}` } as CSSProperties}
188198
>
189199
{decorator ? <div className={styles.decorator}>{decorator}</div> : null}
190-
<textarea
191-
{...props}
192-
onMouseDown={(mouseEvent) => {
193-
wasClicked.current = true;
194-
mouseEvent.currentTarget.dataset.showFocusRing = 'false';
195-
}}
196-
onFocus={(focusEvent) => {
197-
if (!wasClicked.current) {
198-
focusEvent.target.dataset.showFocusRing = 'true';
199-
}
200-
props.onFocus?.(focusEvent);
201-
}}
202-
onBlur={(blurEvent) => {
203-
blurEvent.target.dataset.showFocusRing = 'false';
204-
wasClicked.current = false;
205-
props.onBlur?.(blurEvent);
206-
}}
207-
autoComplete={autocomplete ? 'on' : 'off'}
208-
className={styles.textarea}
209-
disabled={disabled}
210-
onKeyDown={handleKeyDown}
211-
onInput={(event) => setValue(event.currentTarget.value)}
212-
onSelect={(event) => {
213-
if (!selectable) {
214-
event.currentTarget.selectionStart = event.currentTarget.selectionEnd;
215-
}
216-
}}
217-
readOnly={readOnly}
218-
ref={ref}
219-
rows={rows}
220-
value={value}
221-
></textarea>
200+
<div className={styles.textareaWrapper} data-replicated-value={value}>
201+
<textarea
202+
{...props}
203+
onMouseDown={(mouseEvent) => {
204+
wasClicked.current = true;
205+
mouseEvent.currentTarget.dataset.showFocusRing = 'false';
206+
}}
207+
onFocus={(focusEvent) => {
208+
if (!wasClicked.current) {
209+
focusEvent.target.dataset.showFocusRing = 'true';
210+
}
211+
props.onFocus?.(focusEvent);
212+
}}
213+
onBlur={(blurEvent) => {
214+
blurEvent.target.dataset.showFocusRing = 'false';
215+
wasClicked.current = false;
216+
props.onBlur?.(blurEvent);
217+
}}
218+
autoComplete={autocomplete ? 'on' : 'off'}
219+
className={styles.textarea}
220+
disabled={disabled}
221+
onKeyDown={handleKeyDown}
222+
onInput={(event) => setValue(event.currentTarget.value)}
223+
onSelect={(event) => {
224+
if (!selectable) {
225+
event.currentTarget.selectionStart = event.currentTarget.selectionEnd;
226+
}
227+
}}
228+
readOnly={readOnly}
229+
ref={ref}
230+
rows={rows}
231+
value={value}
232+
></textarea>
233+
</div>
222234
{status === 'loading' && <div className={styles.loadingStatus} data-test-id={`${dataTestId}-loader`} />}
223235
{hasTools && (
224236
<div className={styles.tools}>
@@ -250,9 +262,34 @@ const TextareaComponent = (
250262
)}
251263
</div>
252264
)}
265+
{children}
253266
</div>
254267
);
255268
};
269+
TextareaRoot.displayName = 'Textarea.Root';
270+
271+
export type TextareaSlotProps = {
272+
children: ReactNode;
273+
name?: 'left' | 'right';
274+
className?: string;
275+
};
276+
277+
export const TextareaSlot = (
278+
{ name, className, ...slotProps }: TextareaSlotProps,
279+
forwardedRef: ForwardedRef<HTMLDivElement>,
280+
) => {
281+
return <div data-slot data-name={name} {...slotProps} ref={forwardedRef} className={cn(styles.slot, className)} />;
282+
};
283+
284+
TextareaSlot.displayName = 'Textarea.Slot';
285+
286+
const ForwardedRefTextareaRoot = forwardRef<HTMLTextAreaElement, TextareaProps>(TextareaRoot);
287+
const ForwardedRefTextareaSlot = forwardRef<HTMLDivElement, TextareaSlotProps>(TextareaSlot);
256288

257-
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(TextareaComponent);
258-
Textarea.displayName = 'Textarea';
289+
// @ts-expect-error We support both single component (without slots) and compound components (with slots)
290+
export const Textarea: typeof TextareaRoot & {
291+
Root: typeof ForwardedRefTextareaRoot;
292+
Slot: typeof ForwardedRefTextareaSlot;
293+
} = ForwardedRefTextareaRoot;
294+
Textarea.Root = ForwardedRefTextareaRoot;
295+
Textarea.Slot = ForwardedRefTextareaSlot;

0 commit comments

Comments
 (0)