notionsoft-ui 1.0.29 → 1.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/notion-ui/multi-tab-input/index.ts +3 -0
- package/src/notion-ui/multi-tab-input/multi-tab-input.tsx +196 -0
- package/src/notion-ui/multi-tab-input/multi.tab.input.stories.tsx +290 -0
- package/src/notion-ui/multi-tab-textarea/index.ts +3 -0
- package/src/notion-ui/multi-tab-textarea/multi-tab-textarea.tsx +191 -0
- package/src/notion-ui/multi-tab-textarea/multi.tab.textarea.stories.tsx +340 -0
- package/src/notion-ui/phone-input/phone-input.tsx +1 -0
- package/src/notion-ui/tab/index.ts +3 -0
- package/src/notion-ui/tab/tab.tsx +46 -0
- package/src/notion-ui/textarea/textarea.tsx +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import React, { type ReactElement, useState, useMemo } from "react";
|
|
2
|
+
import { cn } from "../../utils/cn";
|
|
3
|
+
import AnimatedItem from "../animated-item";
|
|
4
|
+
import type { TabState } from "../tab/tab";
|
|
5
|
+
import Input, { NastranInputSize } from "../input/input";
|
|
6
|
+
import Tab from "../tab/tab";
|
|
7
|
+
|
|
8
|
+
// OptionalTabs wrapper
|
|
9
|
+
export function OptionalTabs({ children }: { children: React.ReactNode }) {
|
|
10
|
+
return <>{children}</>;
|
|
11
|
+
}
|
|
12
|
+
OptionalTabs.displayName = "OptionalTabs";
|
|
13
|
+
|
|
14
|
+
export interface MultiTabInputProps
|
|
15
|
+
extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
16
|
+
children:
|
|
17
|
+
| ReactElement<typeof Tab>
|
|
18
|
+
| ReactElement<typeof Tab>[]
|
|
19
|
+
| ReactElement<typeof OptionalTabs>;
|
|
20
|
+
tabData: Record<string, string>;
|
|
21
|
+
errorData?: Map<string, string>;
|
|
22
|
+
onTabChanged?: (key: string, data: string, optional?: boolean) => void;
|
|
23
|
+
onChanged: (value: string, name: string) => void;
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
label?: string;
|
|
26
|
+
name: string;
|
|
27
|
+
classNames?: {
|
|
28
|
+
tabsDivClassName?: string;
|
|
29
|
+
rootDivClassName?: string;
|
|
30
|
+
};
|
|
31
|
+
measurement?: NastranInputSize;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MultiTabInput = React.forwardRef<HTMLInputElement, MultiTabInputProps>(
|
|
35
|
+
(props, ref) => {
|
|
36
|
+
const {
|
|
37
|
+
className,
|
|
38
|
+
name,
|
|
39
|
+
classNames,
|
|
40
|
+
children,
|
|
41
|
+
tabData,
|
|
42
|
+
errorData,
|
|
43
|
+
onTabChanged,
|
|
44
|
+
onChanged,
|
|
45
|
+
placeholder,
|
|
46
|
+
label,
|
|
47
|
+
measurement,
|
|
48
|
+
...rest
|
|
49
|
+
} = props;
|
|
50
|
+
const { tabsDivClassName, rootDivClassName } = classNames || {};
|
|
51
|
+
// Separate mandatory and optional tabs (memoized)
|
|
52
|
+
const { mandatoryTabs, optionalTabs } = useMemo(() => {
|
|
53
|
+
const mandatory: React.ReactElement<any>[] = [];
|
|
54
|
+
const optional: React.ReactElement<any>[] = [];
|
|
55
|
+
|
|
56
|
+
React.Children.forEach(children, (child) => {
|
|
57
|
+
if (!React.isValidElement(child)) return;
|
|
58
|
+
|
|
59
|
+
// Type-safe element access
|
|
60
|
+
const element = child as React.ReactElement<any>;
|
|
61
|
+
const typeName = (element.type as any)?.displayName;
|
|
62
|
+
|
|
63
|
+
if (typeName === "OptionalTabs") {
|
|
64
|
+
React.Children.forEach(element.props.children, (c) => {
|
|
65
|
+
if (React.isValidElement(c))
|
|
66
|
+
optional.push(c as React.ReactElement<any>);
|
|
67
|
+
});
|
|
68
|
+
} else if (typeName === "Tab") {
|
|
69
|
+
mandatory.push(element);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return { mandatoryTabs: mandatory, optionalTabs: optional };
|
|
74
|
+
}, [children]);
|
|
75
|
+
|
|
76
|
+
// Initialize state
|
|
77
|
+
const [tabState, setTabState] = useState({
|
|
78
|
+
active: mandatoryTabs[0]?.props.children || "",
|
|
79
|
+
mandatory: mandatoryTabs[0]?.props.children || "",
|
|
80
|
+
optional: optionalTabs[0]?.props.children || "",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const selectionName = `${name}_selections`;
|
|
84
|
+
|
|
85
|
+
const handleTabChange = (tabName: string, optional = false) => {
|
|
86
|
+
setTabState((prev) => ({
|
|
87
|
+
...prev,
|
|
88
|
+
active: tabName,
|
|
89
|
+
[optional ? "optional" : "mandatory"]: tabName,
|
|
90
|
+
}));
|
|
91
|
+
onTabChanged?.(selectionName, tabName, optional);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
95
|
+
onChanged(e.target.value, e.target.name);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const generateUniqueName = (base: string, key: string) => `${base}_${key}`;
|
|
99
|
+
|
|
100
|
+
const getTabState = (tabName: string, optional = false): TabState => {
|
|
101
|
+
if (tabName === tabState.active) return "active";
|
|
102
|
+
if (optional)
|
|
103
|
+
return tabName === tabState.optional ? "selected" : "unselected";
|
|
104
|
+
return tabName === tabState.mandatory ? "selected" : "unselected";
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const renderTabs = (tabs: React.ReactElement<any>[], optional = false) =>
|
|
108
|
+
tabs.map((tab, idx) => {
|
|
109
|
+
const tabName = tab.props.children;
|
|
110
|
+
const state: TabState = getTabState(tabName, optional);
|
|
111
|
+
|
|
112
|
+
return React.cloneElement(tab, {
|
|
113
|
+
key: `${optional ? "opt" : "mand"}-${idx}`,
|
|
114
|
+
state,
|
|
115
|
+
optional,
|
|
116
|
+
onClick: () => handleTabChange(tabName, optional),
|
|
117
|
+
className: tab.props.className,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const activeTabName = generateUniqueName(name, tabState.active);
|
|
122
|
+
const selectTabValue = tabData[activeTabName] || "";
|
|
123
|
+
const errorMessages = errorData?.get(activeTabName)
|
|
124
|
+
? [errorData.get(activeTabName)!]
|
|
125
|
+
: [];
|
|
126
|
+
|
|
127
|
+
const direction =
|
|
128
|
+
activeTabName.endsWith("farsi") || activeTabName.endsWith("pashto")
|
|
129
|
+
? "rtl"
|
|
130
|
+
: "ltr";
|
|
131
|
+
return (
|
|
132
|
+
<div className={cn("flex flex-col select-none", rootDivClassName)}>
|
|
133
|
+
<div className="flex flex-col-reverse sm:flex-row sm:justify-between sm:items-end gap-4">
|
|
134
|
+
{label && (
|
|
135
|
+
<h1 className="font-semibold relative top-1 rtl:text-lg ltr:text-[13px] text-start">
|
|
136
|
+
{label}
|
|
137
|
+
</h1>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
<div
|
|
141
|
+
className={cn(
|
|
142
|
+
"flex flex-wrap gap-2 items-center",
|
|
143
|
+
tabsDivClassName
|
|
144
|
+
)}
|
|
145
|
+
>
|
|
146
|
+
{renderTabs(mandatoryTabs)}
|
|
147
|
+
{optionalTabs.length > 0 && (
|
|
148
|
+
<>
|
|
149
|
+
<span className="bg-primary/30 sm:w-px w-full h-px sm:my-0 sm:mx-2 sm:h-4" />
|
|
150
|
+
{renderTabs(optionalTabs, true)}
|
|
151
|
+
</>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<Input
|
|
157
|
+
measurement={measurement}
|
|
158
|
+
dir={direction}
|
|
159
|
+
{...rest}
|
|
160
|
+
ref={ref}
|
|
161
|
+
name={activeTabName}
|
|
162
|
+
value={selectTabValue}
|
|
163
|
+
placeholder={placeholder}
|
|
164
|
+
onChange={handleInputChange}
|
|
165
|
+
className={cn(
|
|
166
|
+
`mt-2 ${errorMessages.length > 0 && "border-red-400 border-b!"}`,
|
|
167
|
+
className
|
|
168
|
+
)}
|
|
169
|
+
/>
|
|
170
|
+
|
|
171
|
+
{errorMessages.map((error: string, index) => (
|
|
172
|
+
<AnimatedItem
|
|
173
|
+
key={index}
|
|
174
|
+
springProps={{
|
|
175
|
+
from: { opacity: 0, transform: "translateY(-8px)" },
|
|
176
|
+
to: {
|
|
177
|
+
opacity: 1,
|
|
178
|
+
transform: "translateY(0px)",
|
|
179
|
+
delay: index * 100,
|
|
180
|
+
},
|
|
181
|
+
config: { mass: 1, tension: 210, friction: 20 },
|
|
182
|
+
delay: index * 100,
|
|
183
|
+
}}
|
|
184
|
+
intersectionArgs={{ once: true, rootMargin: "-5% 0%" }}
|
|
185
|
+
>
|
|
186
|
+
<h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-[11px]">
|
|
187
|
+
{error}
|
|
188
|
+
</h1>
|
|
189
|
+
</AnimatedItem>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
export default MultiTabInput;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
|
|
4
|
+
import MultiTabInput, { OptionalTabs } from "./multi-tab-input";
|
|
5
|
+
import Tab from "../tab/tab";
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof MultiTabInput> = {
|
|
8
|
+
title: "Form/MultiTabInput",
|
|
9
|
+
component: MultiTabInput,
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: "centered",
|
|
12
|
+
docs: {
|
|
13
|
+
description: {
|
|
14
|
+
component:
|
|
15
|
+
"An input component with tabbed sections for managing multiple related text inputs. Supports mandatory and optional tabs with automatic RTL/LTR text direction detection.",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
argTypes: {
|
|
20
|
+
onChanged: { action: "changed" },
|
|
21
|
+
onTabChanged: { action: "tab changed" },
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
|
|
27
|
+
type Story = StoryObj<typeof MultiTabInput>;
|
|
28
|
+
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
render: (args) => {
|
|
31
|
+
const [tabData, setTabData] = useState<Record<string, string>>({
|
|
32
|
+
title_english: "Sample product title",
|
|
33
|
+
title_spanish: "Título de producto de ejemplo",
|
|
34
|
+
title_french: "",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const [errors] = useState<Map<string, string>>(
|
|
38
|
+
new Map([["title_french", "French title is required"]])
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="w-[400px]">
|
|
43
|
+
<MultiTabInput
|
|
44
|
+
{...args}
|
|
45
|
+
name="title"
|
|
46
|
+
label="Product Title"
|
|
47
|
+
placeholder="Enter title..."
|
|
48
|
+
tabData={tabData}
|
|
49
|
+
errorData={errors}
|
|
50
|
+
onChanged={(value, name) =>
|
|
51
|
+
setTabData((prev) => ({ ...prev, [name]: value }))
|
|
52
|
+
}
|
|
53
|
+
>
|
|
54
|
+
<Tab>english</Tab>
|
|
55
|
+
<Tab>spanish</Tab>
|
|
56
|
+
|
|
57
|
+
<OptionalTabs>
|
|
58
|
+
<Tab>french</Tab>
|
|
59
|
+
</OptionalTabs>
|
|
60
|
+
</MultiTabInput>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const WithoutOptionalTabs: Story = {
|
|
67
|
+
render: () => {
|
|
68
|
+
const [tabData, setTabData] = useState<Record<string, string>>({
|
|
69
|
+
username_english: "john_doe",
|
|
70
|
+
username_german: "johndoe_de",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="w-[400px]">
|
|
75
|
+
<MultiTabInput
|
|
76
|
+
name="username"
|
|
77
|
+
label="Username"
|
|
78
|
+
placeholder="Enter username"
|
|
79
|
+
tabData={tabData}
|
|
80
|
+
onChanged={(value, name) =>
|
|
81
|
+
setTabData((prev) => ({ ...prev, [name]: value }))
|
|
82
|
+
}
|
|
83
|
+
>
|
|
84
|
+
<Tab>english</Tab>
|
|
85
|
+
<Tab>german</Tab>
|
|
86
|
+
</MultiTabInput>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const WithRTLTabs: Story = {
|
|
93
|
+
render: () => {
|
|
94
|
+
const [tabData, setTabData] = useState<Record<string, string>>({
|
|
95
|
+
name_english: "International Company",
|
|
96
|
+
name_arabic: "شركة دولية",
|
|
97
|
+
name_farsi: "شرکت بینالمللی",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const [errors] = useState<Map<string, string>>(
|
|
101
|
+
new Map([
|
|
102
|
+
["name_arabic", "Arabic name is required"],
|
|
103
|
+
["name_farsi", "نام فارسی الزامی است"],
|
|
104
|
+
])
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div className="w-[400px]">
|
|
109
|
+
<MultiTabInput
|
|
110
|
+
name="name"
|
|
111
|
+
label="Company Name"
|
|
112
|
+
placeholder="Enter company name"
|
|
113
|
+
tabData={tabData}
|
|
114
|
+
errorData={errors}
|
|
115
|
+
onChanged={(value, name) =>
|
|
116
|
+
setTabData((prev) => ({ ...prev, [name]: value }))
|
|
117
|
+
}
|
|
118
|
+
>
|
|
119
|
+
<Tab>english</Tab>
|
|
120
|
+
|
|
121
|
+
<OptionalTabs>
|
|
122
|
+
<Tab>arabic</Tab>
|
|
123
|
+
<Tab>farsi</Tab>
|
|
124
|
+
<Tab>pashto</Tab>
|
|
125
|
+
</OptionalTabs>
|
|
126
|
+
</MultiTabInput>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const WithMultipleOptionalTabs: Story = {
|
|
133
|
+
render: () => {
|
|
134
|
+
const [tabData, setTabData] = useState<Record<string, string>>({
|
|
135
|
+
sku_basic: "SKU-001",
|
|
136
|
+
sku_internal: "INT-98432",
|
|
137
|
+
sku_vendor: "VND-7781",
|
|
138
|
+
sku_seo: "premium-product-sku",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className="w-[400px]">
|
|
143
|
+
<MultiTabInput
|
|
144
|
+
name="sku"
|
|
145
|
+
label="Product SKU"
|
|
146
|
+
placeholder="Enter SKU"
|
|
147
|
+
tabData={tabData}
|
|
148
|
+
onChanged={(value, name) =>
|
|
149
|
+
setTabData((prev) => ({ ...prev, [name]: value }))
|
|
150
|
+
}
|
|
151
|
+
classNames={{
|
|
152
|
+
tabsDivClassName: "bg-gray-50 p-2 rounded-lg",
|
|
153
|
+
rootDivClassName: "p-4 border rounded-lg",
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
<Tab>basic</Tab>
|
|
157
|
+
<Tab>internal</Tab>
|
|
158
|
+
|
|
159
|
+
<OptionalTabs>
|
|
160
|
+
<Tab>vendor</Tab>
|
|
161
|
+
<Tab>seo</Tab>
|
|
162
|
+
</OptionalTabs>
|
|
163
|
+
</MultiTabInput>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const WithErrorStates: Story = {
|
|
170
|
+
render: () => {
|
|
171
|
+
const [tabData, setTabData] = useState<Record<string, string>>({
|
|
172
|
+
email_english: "test@",
|
|
173
|
+
email_spanish: "correo@",
|
|
174
|
+
email_french: "email@",
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const [errors] = useState<Map<string, string>>(
|
|
178
|
+
new Map([
|
|
179
|
+
["email_english", "Invalid email address"],
|
|
180
|
+
["email_spanish", "Correo electrónico inválido"],
|
|
181
|
+
["email_french", "Adresse e-mail invalide"],
|
|
182
|
+
])
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div className="w-[400px]">
|
|
187
|
+
<MultiTabInput
|
|
188
|
+
name="email"
|
|
189
|
+
label="Contact Email"
|
|
190
|
+
placeholder="Enter email"
|
|
191
|
+
tabData={tabData}
|
|
192
|
+
errorData={errors}
|
|
193
|
+
onChanged={(value, name) =>
|
|
194
|
+
setTabData((prev) => ({ ...prev, [name]: value }))
|
|
195
|
+
}
|
|
196
|
+
>
|
|
197
|
+
<Tab>english</Tab>
|
|
198
|
+
<Tab>spanish</Tab>
|
|
199
|
+
|
|
200
|
+
<OptionalTabs>
|
|
201
|
+
<Tab>french</Tab>
|
|
202
|
+
</OptionalTabs>
|
|
203
|
+
</MultiTabInput>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export const DisabledState: Story = {
|
|
210
|
+
render: () => {
|
|
211
|
+
const [tabData] = useState<Record<string, string>>({
|
|
212
|
+
code_english: "READ_ONLY_001",
|
|
213
|
+
code_spanish: "SOLO_LECTURA_001",
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div className="w-[400px]">
|
|
218
|
+
<MultiTabInput
|
|
219
|
+
name="code"
|
|
220
|
+
label="Reference Code"
|
|
221
|
+
placeholder="Read only"
|
|
222
|
+
tabData={tabData}
|
|
223
|
+
disabled
|
|
224
|
+
onChanged={() => {}}
|
|
225
|
+
>
|
|
226
|
+
<Tab>english</Tab>
|
|
227
|
+
<Tab>spanish</Tab>
|
|
228
|
+
</MultiTabInput>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
export const InFormContext: Story = {
|
|
235
|
+
render: () => {
|
|
236
|
+
const [formData, setFormData] = useState({
|
|
237
|
+
price: "",
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const [tabData, setTabData] = useState<Record<string, string>>({
|
|
241
|
+
title_english: "",
|
|
242
|
+
title_spanish: "",
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
246
|
+
e.preventDefault();
|
|
247
|
+
console.log({
|
|
248
|
+
...formData,
|
|
249
|
+
...tabData,
|
|
250
|
+
});
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<form onSubmit={handleSubmit} className="w-[400px] space-y-4">
|
|
255
|
+
<div>
|
|
256
|
+
<label className="block text-sm font-medium mb-1">Price</label>
|
|
257
|
+
<input
|
|
258
|
+
type="number"
|
|
259
|
+
className="w-full px-3 py-2 border rounded-md"
|
|
260
|
+
value={formData.price}
|
|
261
|
+
onChange={(e) =>
|
|
262
|
+
setFormData((prev) => ({ ...prev, price: e.target.value }))
|
|
263
|
+
}
|
|
264
|
+
placeholder="Enter price"
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<MultiTabInput
|
|
269
|
+
name="title"
|
|
270
|
+
label="Product Title"
|
|
271
|
+
placeholder="Enter title"
|
|
272
|
+
tabData={tabData}
|
|
273
|
+
onChanged={(value, name) =>
|
|
274
|
+
setTabData((prev) => ({ ...prev, [name]: value }))
|
|
275
|
+
}
|
|
276
|
+
>
|
|
277
|
+
<Tab>english</Tab>
|
|
278
|
+
<Tab>spanish</Tab>
|
|
279
|
+
</MultiTabInput>
|
|
280
|
+
|
|
281
|
+
<button
|
|
282
|
+
type="submit"
|
|
283
|
+
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
|
284
|
+
>
|
|
285
|
+
Save Product
|
|
286
|
+
</button>
|
|
287
|
+
</form>
|
|
288
|
+
);
|
|
289
|
+
},
|
|
290
|
+
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import React, { type ReactElement, useState, useMemo } from "react";
|
|
2
|
+
import { cn } from "../../utils/cn";
|
|
3
|
+
import Textarea from "../textarea";
|
|
4
|
+
import AnimatedItem from "../animated-item";
|
|
5
|
+
import type { TabState } from "../tab/tab";
|
|
6
|
+
import Tab from "../tab/tab";
|
|
7
|
+
|
|
8
|
+
// OptionalTabs wrapper
|
|
9
|
+
export function OptionalTabs({ children }: { children: React.ReactNode }) {
|
|
10
|
+
return <>{children}</>;
|
|
11
|
+
}
|
|
12
|
+
OptionalTabs.displayName = "OptionalTabs";
|
|
13
|
+
|
|
14
|
+
export interface MultiTabTextareaProps
|
|
15
|
+
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
16
|
+
children:
|
|
17
|
+
| ReactElement<typeof Tab>
|
|
18
|
+
| ReactElement<typeof Tab>[]
|
|
19
|
+
| ReactElement<typeof OptionalTabs>;
|
|
20
|
+
tabData: Record<string, string>;
|
|
21
|
+
errorData?: Map<string, string>;
|
|
22
|
+
onTabChanged?: (key: string, data: string, optional?: boolean) => void;
|
|
23
|
+
onChanged: (value: string, name: string) => void;
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
label?: string;
|
|
26
|
+
name: string;
|
|
27
|
+
classNames?: {
|
|
28
|
+
tabsDivClassName?: string;
|
|
29
|
+
rootDivClassName?: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MultiTabTextarea = React.forwardRef<
|
|
34
|
+
HTMLTextAreaElement,
|
|
35
|
+
MultiTabTextareaProps
|
|
36
|
+
>((props, ref) => {
|
|
37
|
+
const {
|
|
38
|
+
className,
|
|
39
|
+
name,
|
|
40
|
+
classNames,
|
|
41
|
+
children,
|
|
42
|
+
tabData,
|
|
43
|
+
errorData,
|
|
44
|
+
onTabChanged,
|
|
45
|
+
onChanged,
|
|
46
|
+
placeholder,
|
|
47
|
+
label,
|
|
48
|
+
...rest
|
|
49
|
+
} = props;
|
|
50
|
+
const { tabsDivClassName, rootDivClassName } = classNames || {};
|
|
51
|
+
// Separate mandatory and optional tabs (memoized)
|
|
52
|
+
const { mandatoryTabs, optionalTabs } = useMemo(() => {
|
|
53
|
+
const mandatory: React.ReactElement<any>[] = [];
|
|
54
|
+
const optional: React.ReactElement<any>[] = [];
|
|
55
|
+
|
|
56
|
+
React.Children.forEach(children, (child) => {
|
|
57
|
+
if (!React.isValidElement(child)) return;
|
|
58
|
+
|
|
59
|
+
// Type-safe element access
|
|
60
|
+
const element = child as React.ReactElement<any>;
|
|
61
|
+
const typeName = (element.type as any)?.displayName;
|
|
62
|
+
|
|
63
|
+
if (typeName === "OptionalTabs") {
|
|
64
|
+
React.Children.forEach(element.props.children, (c) => {
|
|
65
|
+
if (React.isValidElement(c))
|
|
66
|
+
optional.push(c as React.ReactElement<any>);
|
|
67
|
+
});
|
|
68
|
+
} else if (typeName === "Tab") {
|
|
69
|
+
mandatory.push(element);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return { mandatoryTabs: mandatory, optionalTabs: optional };
|
|
74
|
+
}, [children]);
|
|
75
|
+
|
|
76
|
+
// Initialize state
|
|
77
|
+
const [tabState, setTabState] = useState({
|
|
78
|
+
active: mandatoryTabs[0]?.props.children || "",
|
|
79
|
+
mandatory: mandatoryTabs[0]?.props.children || "",
|
|
80
|
+
optional: optionalTabs[0]?.props.children || "",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const selectionName = `${name}_selections`;
|
|
84
|
+
|
|
85
|
+
const handleTabChange = (tabName: string, optional = false) => {
|
|
86
|
+
setTabState((prev) => ({
|
|
87
|
+
...prev,
|
|
88
|
+
active: tabName,
|
|
89
|
+
[optional ? "optional" : "mandatory"]: tabName,
|
|
90
|
+
}));
|
|
91
|
+
onTabChanged?.(selectionName, tabName, optional);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
95
|
+
onChanged(e.target.value, e.target.name);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const generateUniqueName = (base: string, key: string) => `${base}_${key}`;
|
|
99
|
+
|
|
100
|
+
const getTabState = (tabName: string, optional = false): TabState => {
|
|
101
|
+
if (tabName === tabState.active) return "active";
|
|
102
|
+
if (optional)
|
|
103
|
+
return tabName === tabState.optional ? "selected" : "unselected";
|
|
104
|
+
return tabName === tabState.mandatory ? "selected" : "unselected";
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const renderTabs = (tabs: React.ReactElement<any>[], optional = false) =>
|
|
108
|
+
tabs.map((tab, idx) => {
|
|
109
|
+
const tabName = tab.props.children;
|
|
110
|
+
const state: TabState = getTabState(tabName, optional);
|
|
111
|
+
|
|
112
|
+
return React.cloneElement(tab, {
|
|
113
|
+
key: `${optional ? "opt" : "mand"}-${idx}`,
|
|
114
|
+
state,
|
|
115
|
+
optional,
|
|
116
|
+
onClick: () => handleTabChange(tabName, optional),
|
|
117
|
+
className: tab.props.className,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const activeTabName = generateUniqueName(name, tabState.active);
|
|
122
|
+
const selectTabValue = tabData[activeTabName] || "";
|
|
123
|
+
const errorMessages = errorData?.get(activeTabName)
|
|
124
|
+
? [errorData.get(activeTabName)!]
|
|
125
|
+
: [];
|
|
126
|
+
|
|
127
|
+
const direction =
|
|
128
|
+
activeTabName.endsWith("farsi") || activeTabName.endsWith("pashto")
|
|
129
|
+
? "rtl"
|
|
130
|
+
: "ltr";
|
|
131
|
+
return (
|
|
132
|
+
<div className={cn("flex flex-col select-none", rootDivClassName)}>
|
|
133
|
+
<div className="flex flex-col-reverse sm:flex-row sm:justify-between sm:items-end gap-4">
|
|
134
|
+
{label && (
|
|
135
|
+
<h1 className="font-semibold relative top-1 rtl:text-lg ltr:text-[13px] text-start">
|
|
136
|
+
{label}
|
|
137
|
+
</h1>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
<div
|
|
141
|
+
className={cn("flex flex-wrap gap-2 items-center", tabsDivClassName)}
|
|
142
|
+
>
|
|
143
|
+
{renderTabs(mandatoryTabs)}
|
|
144
|
+
{optionalTabs.length > 0 && (
|
|
145
|
+
<>
|
|
146
|
+
<span className="bg-primary/30 sm:w-px w-full h-px sm:my-0 sm:mx-2 sm:h-4" />
|
|
147
|
+
{renderTabs(optionalTabs, true)}
|
|
148
|
+
</>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<Textarea
|
|
154
|
+
dir={direction}
|
|
155
|
+
{...rest}
|
|
156
|
+
ref={ref}
|
|
157
|
+
name={activeTabName}
|
|
158
|
+
value={selectTabValue}
|
|
159
|
+
placeholder={placeholder}
|
|
160
|
+
onChange={handleInputChange}
|
|
161
|
+
className={cn(
|
|
162
|
+
`mt-2 ${errorMessages.length > 0 ? "border-red-400 border-b!" : ""}`,
|
|
163
|
+
className
|
|
164
|
+
)}
|
|
165
|
+
/>
|
|
166
|
+
|
|
167
|
+
{errorMessages.map((error: string, index) => (
|
|
168
|
+
<AnimatedItem
|
|
169
|
+
key={index}
|
|
170
|
+
springProps={{
|
|
171
|
+
from: { opacity: 0, transform: "translateY(-8px)" },
|
|
172
|
+
to: {
|
|
173
|
+
opacity: 1,
|
|
174
|
+
transform: "translateY(0px)",
|
|
175
|
+
delay: index * 100,
|
|
176
|
+
},
|
|
177
|
+
config: { mass: 1, tension: 210, friction: 20 },
|
|
178
|
+
delay: index * 100,
|
|
179
|
+
}}
|
|
180
|
+
intersectionArgs={{ once: true, rootMargin: "-5% 0%" }}
|
|
181
|
+
>
|
|
182
|
+
<h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-[11px]">
|
|
183
|
+
{error}
|
|
184
|
+
</h1>
|
|
185
|
+
</AnimatedItem>
|
|
186
|
+
))}
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
export default MultiTabTextarea;
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
|
|
4
|
+
import MultiTabTextarea, { OptionalTabs } from "./multi-tab-textarea";
|
|
5
|
+
import Tab from "../tab/tab";
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof MultiTabTextarea> = {
|
|
8
|
+
title: "Form/MultiTabTextarea",
|
|
9
|
+
component: MultiTabTextarea,
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: "centered",
|
|
12
|
+
docs: {
|
|
13
|
+
description: {
|
|
14
|
+
component:
|
|
15
|
+
"A textarea component with tabbed sections for managing multiple related text inputs. Supports mandatory and optional tabs with automatic RTL/LTR text direction detection.",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
argTypes: {
|
|
20
|
+
onChanged: { action: "changed" },
|
|
21
|
+
onTabChanged: { action: "tab changed" },
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
|
|
27
|
+
type Story = StoryObj<typeof MultiTabTextarea>;
|
|
28
|
+
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
render: (args) => {
|
|
31
|
+
const [tabData, setTabData] = useState<Record<string, string>>({
|
|
32
|
+
description_en: "This is a sample product description.",
|
|
33
|
+
description_es: "Esta es una descripción de producto de muestra.",
|
|
34
|
+
description_fr: "",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const [errors, setErrors] = useState<Map<string, string>>(
|
|
38
|
+
new Map([["description_fr", "French description is required"]])
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const handleChange = (value: string, name: string) => {
|
|
42
|
+
setTabData((prev) => ({
|
|
43
|
+
...prev,
|
|
44
|
+
[name]: value,
|
|
45
|
+
}));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const handleTabChanged = (
|
|
49
|
+
key: string,
|
|
50
|
+
value: string,
|
|
51
|
+
optional?: boolean
|
|
52
|
+
) => {
|
|
53
|
+
console.log("Tab changed:", { key, value, optional });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="w-[500px]">
|
|
58
|
+
<MultiTabTextarea
|
|
59
|
+
{...args}
|
|
60
|
+
name="description"
|
|
61
|
+
label="Product Description"
|
|
62
|
+
placeholder="Enter your description here..."
|
|
63
|
+
tabData={tabData}
|
|
64
|
+
errorData={errors}
|
|
65
|
+
onChanged={handleChange}
|
|
66
|
+
onTabChanged={handleTabChanged}
|
|
67
|
+
>
|
|
68
|
+
{/* Mandatory tabs */}
|
|
69
|
+
<Tab>english</Tab>
|
|
70
|
+
<Tab>spanish</Tab>
|
|
71
|
+
|
|
72
|
+
{/* Optional tabs */}
|
|
73
|
+
<OptionalTabs>
|
|
74
|
+
<Tab>french</Tab>
|
|
75
|
+
</OptionalTabs>
|
|
76
|
+
</MultiTabTextarea>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const WithoutOptionalTabs: Story = {
|
|
83
|
+
render: () => {
|
|
84
|
+
const [tabData, setTabData] = useState<Record<string, string>>({
|
|
85
|
+
bio_en: "I'm a software developer with 5 years of experience.",
|
|
86
|
+
bio_de: "Ich bin Softwareentwickler mit 5 Jahren Erfahrung.",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="w-[500px]">
|
|
91
|
+
<MultiTabTextarea
|
|
92
|
+
name="bio"
|
|
93
|
+
label="Biography"
|
|
94
|
+
placeholder="Tell us about yourself..."
|
|
95
|
+
tabData={tabData}
|
|
96
|
+
onChanged={(value, name) =>
|
|
97
|
+
setTabData((prev) => ({ ...prev, [name]: value }))
|
|
98
|
+
}
|
|
99
|
+
>
|
|
100
|
+
<Tab>english</Tab>
|
|
101
|
+
<Tab>german</Tab>
|
|
102
|
+
</MultiTabTextarea>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const WithRTLTabs: Story = {
|
|
109
|
+
render: () => {
|
|
110
|
+
const [tabData, setTabData] = useState<Record<string, string>>({
|
|
111
|
+
content_en: "Welcome to our international platform.",
|
|
112
|
+
content_ar: "مرحبًا بكم في منصتنا الدولية.",
|
|
113
|
+
content_fa: "به پلتفرم بینالمللی ما خوش آمدید.",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const [errors, setErrors] = useState<Map<string, string>>(
|
|
117
|
+
new Map([
|
|
118
|
+
["content_ar", "Arabic content must be at least 20 characters"],
|
|
119
|
+
["content_fa", "فارسی باید حداقل 20 کاراکتر باشد"],
|
|
120
|
+
])
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="w-[500px]">
|
|
125
|
+
<MultiTabTextarea
|
|
126
|
+
name="content"
|
|
127
|
+
label="Content"
|
|
128
|
+
placeholder="Type your content here..."
|
|
129
|
+
tabData={tabData}
|
|
130
|
+
errorData={errors}
|
|
131
|
+
onChanged={(value, name) =>
|
|
132
|
+
setTabData((prev) => ({ ...prev, [name]: value }))
|
|
133
|
+
}
|
|
134
|
+
>
|
|
135
|
+
<Tab>english</Tab>
|
|
136
|
+
<OptionalTabs>
|
|
137
|
+
<Tab>arabic</Tab>
|
|
138
|
+
<Tab>farsi</Tab>
|
|
139
|
+
<Tab>pashto</Tab>
|
|
140
|
+
</OptionalTabs>
|
|
141
|
+
</MultiTabTextarea>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const WithMultipleOptionalTabs: Story = {
|
|
148
|
+
render: () => {
|
|
149
|
+
const [tabData, setTabData] = useState<Record<string, string>>({
|
|
150
|
+
product_details_basic:
|
|
151
|
+
"A premium quality product with excellent durability.",
|
|
152
|
+
product_details_advanced:
|
|
153
|
+
"Made from 100% recycled materials. Water-resistant up to 50m.",
|
|
154
|
+
product_details_technical:
|
|
155
|
+
"Material: Polycarbonate\nWeight: 150g\nDimensions: 10x5x2cm",
|
|
156
|
+
product_details_seo:
|
|
157
|
+
"Premium durable product for everyday use. Best value for money.",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div className="w-[500px]">
|
|
162
|
+
<MultiTabTextarea
|
|
163
|
+
name="product_details"
|
|
164
|
+
label="Product Details"
|
|
165
|
+
placeholder="Enter product information..."
|
|
166
|
+
tabData={tabData}
|
|
167
|
+
onChanged={(value, name) =>
|
|
168
|
+
setTabData((prev) => ({ ...prev, [name]: value }))
|
|
169
|
+
}
|
|
170
|
+
classNames={{
|
|
171
|
+
tabsDivClassName: "bg-gray-50 p-2 rounded-lg",
|
|
172
|
+
rootDivClassName: "p-4 border rounded-lg",
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
<Tab>basic</Tab>
|
|
176
|
+
<Tab>advanced</Tab>
|
|
177
|
+
<OptionalTabs>
|
|
178
|
+
<Tab>technical</Tab>
|
|
179
|
+
<Tab>seo</Tab>
|
|
180
|
+
</OptionalTabs>
|
|
181
|
+
</MultiTabTextarea>
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export const WithErrorStates: Story = {
|
|
188
|
+
render: () => {
|
|
189
|
+
const [tabData, setTabData] = useState<Record<string, string>>({
|
|
190
|
+
review_en: "This product is",
|
|
191
|
+
review_es: "Este producto es",
|
|
192
|
+
review_fr: "Ce produit est",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const [errors, setErrors] = useState<Map<string, string>>(
|
|
196
|
+
new Map([
|
|
197
|
+
["review_en", "Review must be at least 20 characters"],
|
|
198
|
+
["review_es", "La reseña debe tener al menos 20 caracteres"],
|
|
199
|
+
["review_fr", "L'avis doit comporter au moins 20 caractères"],
|
|
200
|
+
])
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<div className="w-[500px]">
|
|
205
|
+
<MultiTabTextarea
|
|
206
|
+
name="review"
|
|
207
|
+
label="Product Review"
|
|
208
|
+
placeholder="Write your review here..."
|
|
209
|
+
tabData={tabData}
|
|
210
|
+
errorData={errors}
|
|
211
|
+
onChanged={(value, name) =>
|
|
212
|
+
setTabData((prev) => ({ ...prev, [name]: value }))
|
|
213
|
+
}
|
|
214
|
+
>
|
|
215
|
+
<Tab>english</Tab>
|
|
216
|
+
<Tab>spanish</Tab>
|
|
217
|
+
<OptionalTabs>
|
|
218
|
+
<Tab>french</Tab>
|
|
219
|
+
</OptionalTabs>
|
|
220
|
+
</MultiTabTextarea>
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export const DisabledState: Story = {
|
|
227
|
+
render: () => {
|
|
228
|
+
const [tabData] = useState<Record<string, string>>({
|
|
229
|
+
template_en:
|
|
230
|
+
"This is a read-only template.\nYou cannot edit this content.",
|
|
231
|
+
template_es:
|
|
232
|
+
"Esta es una plantilla de solo lectura.\nNo puedes editar este contenido.",
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<div className="w-[500px]">
|
|
237
|
+
<MultiTabTextarea
|
|
238
|
+
name="template"
|
|
239
|
+
label="Email Template"
|
|
240
|
+
placeholder="Cannot edit - this is read-only"
|
|
241
|
+
tabData={tabData}
|
|
242
|
+
disabled
|
|
243
|
+
onChanged={() => {}}
|
|
244
|
+
>
|
|
245
|
+
<Tab>english</Tab>
|
|
246
|
+
<Tab>spanish</Tab>
|
|
247
|
+
</MultiTabTextarea>
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
export const CustomPlaceholders: Story = {
|
|
254
|
+
render: () => {
|
|
255
|
+
const [tabData, setTabData] = useState<Record<string, string>>({
|
|
256
|
+
instructions_en: "",
|
|
257
|
+
instructions_ar: "",
|
|
258
|
+
instructions_fa: "",
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<div className="w-[500px]">
|
|
263
|
+
<MultiTabTextarea
|
|
264
|
+
name="instructions"
|
|
265
|
+
label="Instructions"
|
|
266
|
+
placeholder="Default placeholder..."
|
|
267
|
+
tabData={tabData}
|
|
268
|
+
onChanged={(value, name) =>
|
|
269
|
+
setTabData((prev) => ({ ...prev, [name]: value }))
|
|
270
|
+
}
|
|
271
|
+
>
|
|
272
|
+
<Tab>english</Tab>
|
|
273
|
+
<Tab>arabic</Tab>
|
|
274
|
+
<OptionalTabs>
|
|
275
|
+
<Tab>farsi</Tab>
|
|
276
|
+
</OptionalTabs>
|
|
277
|
+
</MultiTabTextarea>
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
export const InFormContext: Story = {
|
|
284
|
+
render: () => {
|
|
285
|
+
const [formData, setFormData] = useState({
|
|
286
|
+
name: "",
|
|
287
|
+
description: "",
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const [tabData, setTabData] = useState<Record<string, string>>({
|
|
291
|
+
description_en: "",
|
|
292
|
+
description_es: "",
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
296
|
+
e.preventDefault();
|
|
297
|
+
console.log({
|
|
298
|
+
...formData,
|
|
299
|
+
...tabData,
|
|
300
|
+
});
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<form onSubmit={handleSubmit} className="w-[500px] space-y-4">
|
|
305
|
+
<div>
|
|
306
|
+
<label className="block text-sm font-medium mb-1">Product Name</label>
|
|
307
|
+
<input
|
|
308
|
+
type="text"
|
|
309
|
+
className="w-full px-3 py-2 border rounded-md"
|
|
310
|
+
value={formData.name}
|
|
311
|
+
onChange={(e) =>
|
|
312
|
+
setFormData((prev) => ({ ...prev, name: e.target.value }))
|
|
313
|
+
}
|
|
314
|
+
placeholder="Enter product name"
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
<MultiTabTextarea
|
|
319
|
+
name="description"
|
|
320
|
+
label="Product Description"
|
|
321
|
+
placeholder="Describe your product..."
|
|
322
|
+
tabData={tabData}
|
|
323
|
+
onChanged={(value, name) =>
|
|
324
|
+
setTabData((prev) => ({ ...prev, [name]: value }))
|
|
325
|
+
}
|
|
326
|
+
>
|
|
327
|
+
<Tab>english</Tab>
|
|
328
|
+
<Tab>spanish</Tab>
|
|
329
|
+
</MultiTabTextarea>
|
|
330
|
+
|
|
331
|
+
<button
|
|
332
|
+
type="submit"
|
|
333
|
+
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
|
334
|
+
>
|
|
335
|
+
Save Product
|
|
336
|
+
</button>
|
|
337
|
+
</form>
|
|
338
|
+
);
|
|
339
|
+
},
|
|
340
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { cn } from "../../utils/cn";
|
|
2
|
+
import { Asterisk, CircleDot } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
export type TabState = "active" | "selected" | "unselected";
|
|
5
|
+
|
|
6
|
+
interface TabProps {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
onClick?: () => void;
|
|
10
|
+
translation?: string;
|
|
11
|
+
|
|
12
|
+
state?: TabState; // <-- now strongly typed
|
|
13
|
+
optional?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export default function Tab({
|
|
16
|
+
children,
|
|
17
|
+
className,
|
|
18
|
+
onClick,
|
|
19
|
+
translation,
|
|
20
|
+
state = "unselected",
|
|
21
|
+
optional = false,
|
|
22
|
+
}: TabProps) {
|
|
23
|
+
// Icon for this tab
|
|
24
|
+
const Icon = optional ? CircleDot : Asterisk;
|
|
25
|
+
|
|
26
|
+
const baseClass = cn(
|
|
27
|
+
"capitalize transition px-3 py-1.5 ltr:text-xs rtl:text-[13px] sm:rtl:text-sm rtl:font-semibold px-2 rounded cursor-pointer shadow-md dark:shadow-none dark:border dark:hover:opacity-80 shadow-primary/20 hover:shadow-sm flex items-center gap-1",
|
|
28
|
+
|
|
29
|
+
state === "active"
|
|
30
|
+
? "text-primary-foreground bg-tertiary"
|
|
31
|
+
: state === "selected"
|
|
32
|
+
? "bg-primary/50 text-primary-foreground/90"
|
|
33
|
+
: "bg-primary/10 text-primary/50",
|
|
34
|
+
|
|
35
|
+
className
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div onClick={onClick} className={baseClass}>
|
|
40
|
+
<Icon size={14} className="opacity-70 size-3 text-current" />
|
|
41
|
+
{translation ?? children}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
Tab.displayName = "Tab";
|