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
|
@@ -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 "
|
|
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
|
|
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={
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
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,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";
|