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.
@@ -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,112 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import PhoneInput from "./phone-input";
3
+
4
+ const meta: Meta<typeof PhoneInput> = {
5
+ title: "Form/PhoneInput",
6
+ component: PhoneInput,
7
+ parameters: {
8
+ layout: "centered",
9
+ },
10
+ args: {
11
+ placeholder: "Phone number",
12
+ },
13
+ };
14
+
15
+ export default meta;
16
+
17
+ type Story = StoryObj<typeof PhoneInput>;
18
+
19
+ // ---------------------------------------------------------------------
20
+ // Default
21
+ // ---------------------------------------------------------------------
22
+ export const Default: Story = {
23
+ args: {
24
+ placeholder: "Enter phone number",
25
+ },
26
+ };
27
+
28
+ // ---------------------------------------------------------------------
29
+ // With Label
30
+ // ---------------------------------------------------------------------
31
+ export const WithLabel: Story = {
32
+ args: {
33
+ label: "Phone Number",
34
+ },
35
+ };
36
+
37
+ // ---------------------------------------------------------------------
38
+ // Required Hint (*)
39
+ // ---------------------------------------------------------------------
40
+ export const Required: Story = {
41
+ args: {
42
+ label: "Phone Number",
43
+ requiredHint: "*",
44
+ },
45
+ };
46
+
47
+ // ---------------------------------------------------------------------
48
+ // With Error
49
+ // ---------------------------------------------------------------------
50
+ export const WithError: Story = {
51
+ args: {
52
+ label: "Contact Number",
53
+ errorMessage: "Invalid phone number",
54
+ },
55
+ };
56
+
57
+ // ---------------------------------------------------------------------
58
+ // Pre-filled value
59
+ // ---------------------------------------------------------------------
60
+ export const PreFilled: Story = {
61
+ args: {
62
+ label: "Phone Number",
63
+ value: "+93 700000000",
64
+ },
65
+ };
66
+
67
+ // ---------------------------------------------------------------------
68
+ // Sizes (sm, md, lg)
69
+ // ---------------------------------------------------------------------
70
+ export const Small: Story = {
71
+ args: {
72
+ label: "Small",
73
+ measurement: "sm",
74
+ },
75
+ };
76
+
77
+ export const Medium: Story = {
78
+ args: {
79
+ label: "Medium",
80
+ measurement: "md",
81
+ },
82
+ };
83
+
84
+ export const Large: Story = {
85
+ args: {
86
+ label: "Large",
87
+ measurement: "lg",
88
+ },
89
+ };
90
+
91
+ // ---------------------------------------------------------------------
92
+ // Read-Only
93
+ // ---------------------------------------------------------------------
94
+ export const ReadOnly: Story = {
95
+ args: {
96
+ readOnly: true,
97
+ value: "+93 700000000",
98
+ label: "Read-only Phone",
99
+ },
100
+ };
101
+
102
+ // ---------------------------------------------------------------------
103
+ // Custom Root Class Styles
104
+ // ---------------------------------------------------------------------
105
+ export const CustomRootClass: Story = {
106
+ args: {
107
+ label: "Custom Style",
108
+ classNames: {
109
+ rootDivClassName: "p-4 bg-blue-50 rounded-md",
110
+ },
111
+ },
112
+ };
@@ -1,7 +1,7 @@
1
1
  import { defaultCountries } from "./country-data";
2
2
  import { LazyFlag } from "./lazy-flag";
3
3
  import type { ParsedCountry } from "./type";
4
- import { cn } from "@/utils/cn";
4
+ import { cn } from "../../utils/cn";
5
5
  import React, {
6
6
  useState,
7
7
  useRef,
@@ -10,6 +10,7 @@ import React, {
10
10
  useMemo,
11
11
  } from "react";
12
12
  import { createPortal } from "react-dom";
13
+ import AnimatedItem from "../animated-item";
13
14
 
14
15
  interface VirtualListProps {
15
16
  items: ParsedCountry[];
@@ -100,10 +101,19 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
100
101
  }) => {
101
102
  const { rootDivClassName, iconClassName = "size-4" } = classNames || {};
102
103
  const [open, setOpen] = useState(false);
103
- const [country, setCountry] = useState<ParsedCountry>(defaultCountries[0]);
104
104
  const [highlightedIndex, setHighlightedIndex] = useState<number>(0);
105
+ const initialCountry = (() => {
106
+ if (typeof value === "string" && value.startsWith("+")) {
107
+ const matched = defaultCountries.find((c) =>
108
+ value.startsWith("+" + c.dialCode)
109
+ );
110
+ return matched || defaultCountries[0];
111
+ }
112
+ return defaultCountries[0];
113
+ })();
114
+ const [country, setCountry] = useState<ParsedCountry>(initialCountry);
105
115
  const [phone, setPhone] = useState<string>(
106
- typeof value == "string" ? value : ""
116
+ typeof value === "string" ? value : `+${initialCountry.dialCode}`
107
117
  );
108
118
  const containerRef = useRef<HTMLDivElement>(null);
109
119
  const dropdownRef = useRef<HTMLDivElement>(null);
@@ -118,6 +128,26 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
118
128
  const chooseCountry = (c: ParsedCountry) => {
119
129
  setCountry(c);
120
130
  setOpen(false);
131
+
132
+ setPhone((prev) => {
133
+ const oldDialRegex = new RegExp(`^\\+${country.dialCode}`);
134
+ const restNumber = prev.replace(oldDialRegex, "");
135
+ const newValue = `+${c.dialCode}${restNumber}`;
136
+ if (onChange && inputRef.current) {
137
+ const fakeEvent = {
138
+ target: {
139
+ ...inputRef.current,
140
+ name: inputRef.current.name,
141
+ value: newValue,
142
+ },
143
+ } as React.ChangeEvent<HTMLInputElement>;
144
+
145
+ onChange(fakeEvent);
146
+ }
147
+
148
+ return newValue;
149
+ });
150
+
121
151
  inputRef.current?.focus();
122
152
  };
123
153
 
@@ -244,39 +274,40 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
244
274
  measurement == "lg"
245
275
  ? {
246
276
  height: "50px",
247
- endContent: label
248
- ? "ltr:top-[48px] rtl:top-[54px]-translate-y-1/2"
249
- : "top-[26px] -translate-y-1/2",
250
- startContent: label
251
- ? "ltr:top-[48px] rtl:top-[54px] -translate-y-1/2"
252
- : "top-[26px] -translate-y-1/2",
253
277
  required: label ? "ltr:top-[4px] rtl:top-[12px]" : "top-[-19px]",
254
278
  }
255
279
  : measurement == "md"
256
280
  ? {
257
281
  height: "44px",
258
- endContent: label
259
- ? "ltr:top-[45px] rtl:top-[51px] -translate-y-1/2"
260
- : "top-[22px] -translate-y-1/2",
261
- startContent: label
262
- ? "ltr:top-[45px] rtl:top-[51px] -translate-y-1/2"
263
- : "top-[22px] -translate-y-1/2",
264
282
  required: label ? "ltr:top-[4px] rtl:top-[12px]" : "top-[-19px]",
265
283
  }
266
284
  : {
267
285
  height: "40px",
268
- endContent: label
269
- ? "ltr:top-[44px] rtl:top-[50px] -translate-y-1/2"
270
- : "top-[20px] -translate-y-1/2",
271
- startContent: label
272
- ? "ltr:top-[44px] rtl:top-[50px] -translate-y-1/2"
273
- : "top-[20px] -translate-y-1/2",
274
286
  required: label ? "ltr:top-[4px] rtl:top-[12px]" : "top-[-19px]",
275
287
  },
276
288
  [measurement, label]
277
289
  );
278
290
  const readOnlyStyle = readOnly && "opacity-40";
279
291
 
292
+ const inputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
293
+ let val = e.target.value;
294
+ let name = e.target.name;
295
+
296
+ // Ensure dial code always at start
297
+ if (!val.startsWith(`+${country.dialCode}`)) {
298
+ val = `+${country.dialCode}${val.replace(/^\+\d*/, "")}`;
299
+ }
300
+
301
+ setPhone(val);
302
+ if (onChange) {
303
+ // emit event
304
+ const fakeEvent = {
305
+ ...e,
306
+ target: { ...e.target, name: name, value: val },
307
+ };
308
+ onChange(fakeEvent as React.ChangeEvent<HTMLInputElement>);
309
+ }
310
+ };
280
311
  return (
281
312
  <div
282
313
  className={cn(
@@ -329,11 +360,12 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
329
360
  <input
330
361
  ref={inputRef}
331
362
  type="tel"
332
- value={phone}
333
- onChange={(e) => {
334
- if (onChange) onChange(e);
335
- setPhone(e.target.value);
336
- }}
363
+ value={
364
+ phone.startsWith(`+${country.dialCode}`)
365
+ ? phone
366
+ : `+${country.dialCode}`
367
+ }
368
+ onChange={inputChanged}
337
369
  placeholder="Phone number"
338
370
  style={{
339
371
  height: heightStyle.height,
@@ -351,6 +383,7 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
351
383
  )}
352
384
  {...rest}
353
385
  disabled={readOnly}
386
+ dir="ltr"
354
387
  />
355
388
  </div>
356
389
 
@@ -378,9 +411,8 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
378
411
  renderRow={(c, i) => (
379
412
  <div
380
413
  onClick={() => chooseCountry(c)}
381
- onMouseEnter={() => setHighlightedIndex(i)}
382
414
  className={`flex ltr:text-sm rtl:text-sm rtl:font-semibold items-center gap-2 px-2 py-1 cursor-pointer ${
383
- i == highlightedIndex ? "bg-primary/5" : ""
415
+ i == highlightedIndex && "bg-primary/5"
384
416
  }`}
385
417
  role="option"
386
418
  aria-selected={i === highlightedIndex}
@@ -394,6 +426,31 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
394
426
  </div>,
395
427
  document.body
396
428
  )}
429
+ {/* Error Message */}
430
+ {hasError && (
431
+ <AnimatedItem
432
+ springProps={{
433
+ from: {
434
+ opacity: 0,
435
+ transform: "translateY(-8px)",
436
+ },
437
+ config: {
438
+ mass: 1,
439
+ tension: 210,
440
+ friction: 20,
441
+ },
442
+ to: {
443
+ opacity: 1,
444
+ transform: "translateY(0px)",
445
+ },
446
+ }}
447
+ intersectionArgs={{ once: true, rootMargin: "-5% 0%" }}
448
+ >
449
+ <h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-sm-ltr">
450
+ {errorMessage}
451
+ </h1>
452
+ </AnimatedItem>
453
+ )}
397
454
  </div>
398
455
  );
399
456
  };
@@ -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";