notionsoft-ui 1.0.27 → 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/PhoneInput.stories.tsx +112 -0
- package/src/notion-ui/phone-input/phone-input.tsx +85 -28
- package/src/notion-ui/tab/index.ts +3 -0
- package/src/notion-ui/tab/tab.tsx +46 -0
- package/src/notion-ui/textarea/Textarea.stories.tsx +77 -0
- package/src/notion-ui/textarea/index.ts +3 -0
- package/src/notion-ui/textarea/textarea.tsx +121 -0
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;
|