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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notionsoft-ui",
3
- "version": "1.0.29",
3
+ "version": "1.0.30",
4
4
  "description": "A React UI component installer (shadcn-style). Installs components directly into your project.",
5
5
  "bin": {
6
6
  "notionsoft-ui": "./cli/index.cjs"
@@ -0,0 +1,3 @@
1
+ import MultiTabInput from "./multi-tab-input";
2
+
3
+ export default MultiTabInput;
@@ -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,3 @@
1
+ import MultiTabTextarea from "./multi-tab-textarea";
2
+
3
+ export default MultiTabTextarea;
@@ -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
+ };
@@ -383,6 +383,7 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
383
383
  )}
384
384
  {...rest}
385
385
  disabled={readOnly}
386
+ dir="ltr"
386
387
  />
387
388
  </div>
388
389
 
@@ -0,0 +1,3 @@
1
+ import Tab from "./tab";
2
+
3
+ export default Tab;
@@ -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";
@@ -14,7 +14,7 @@ export interface TextareaProps
14
14
  };
15
15
  }
16
16
 
17
- export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
17
+ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
18
18
  (
19
19
  {
20
20
  className,