notionsoft-ui 1.0.27 → 1.0.29

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.27",
3
+ "version": "1.0.29",
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,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,
@@ -378,9 +410,8 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
378
410
  renderRow={(c, i) => (
379
411
  <div
380
412
  onClick={() => chooseCountry(c)}
381
- onMouseEnter={() => setHighlightedIndex(i)}
382
413
  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" : ""
414
+ i == highlightedIndex && "bg-primary/5"
384
415
  }`}
385
416
  role="option"
386
417
  aria-selected={i === highlightedIndex}
@@ -394,6 +425,31 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
394
425
  </div>,
395
426
  document.body
396
427
  )}
428
+ {/* Error Message */}
429
+ {hasError && (
430
+ <AnimatedItem
431
+ springProps={{
432
+ from: {
433
+ opacity: 0,
434
+ transform: "translateY(-8px)",
435
+ },
436
+ config: {
437
+ mass: 1,
438
+ tension: 210,
439
+ friction: 20,
440
+ },
441
+ to: {
442
+ opacity: 1,
443
+ transform: "translateY(0px)",
444
+ },
445
+ }}
446
+ intersectionArgs={{ once: true, rootMargin: "-5% 0%" }}
447
+ >
448
+ <h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-sm-ltr">
449
+ {errorMessage}
450
+ </h1>
451
+ </AnimatedItem>
452
+ )}
397
453
  </div>
398
454
  );
399
455
  };
@@ -0,0 +1,77 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Textarea } from "./textarea";
3
+
4
+ const meta: Meta<typeof Textarea> = {
5
+ title: "Form/Textarea",
6
+ component: Textarea,
7
+ args: {
8
+ placeholder: "Write something...",
9
+ },
10
+ };
11
+ export default meta;
12
+ type Story = StoryObj<typeof Textarea>;
13
+
14
+ // ---------------------------------------
15
+ // Default
16
+ // ---------------------------------------
17
+ export const Default: Story = {
18
+ args: {
19
+ placeholder: "Enter text...",
20
+ },
21
+ };
22
+
23
+ // ---------------------------------------
24
+ // With Label
25
+ // ---------------------------------------
26
+ export const WithLabel: Story = {
27
+ args: {
28
+ label: "Description",
29
+ placeholder: "Enter description...",
30
+ },
31
+ };
32
+
33
+ // ---------------------------------------
34
+ // Required Hint (*)
35
+ // ---------------------------------------
36
+ export const WithRequired: Story = {
37
+ args: {
38
+ label: "Bio",
39
+ requiredHint: "*",
40
+ placeholder: "Tell us about yourself...",
41
+ },
42
+ };
43
+
44
+ // ---------------------------------------
45
+ // With Error Message (AnimatedItem visible)
46
+ // ---------------------------------------
47
+ export const WithError: Story = {
48
+ args: {
49
+ label: "Comment",
50
+ errorMessage: "Comment is required",
51
+ placeholder: "Add a comment...",
52
+ },
53
+ };
54
+
55
+ // ---------------------------------------
56
+ // ReadOnly Example
57
+ // ---------------------------------------
58
+ export const ReadOnly: Story = {
59
+ args: {
60
+ label: "Readonly Field",
61
+ readOnly: true,
62
+ value: "This textarea cannot be edited",
63
+ },
64
+ };
65
+
66
+ // ---------------------------------------
67
+ // Custom Root Class via classNames
68
+ // ---------------------------------------
69
+ export const CustomRootClass: Story = {
70
+ args: {
71
+ label: "Custom Styled",
72
+ placeholder: "Root custom style applied",
73
+ classNames: {
74
+ rootDivClassName: "bg-blue-50 p-3 rounded-lg",
75
+ },
76
+ },
77
+ };
@@ -0,0 +1,3 @@
1
+ import Textarea from "./textarea";
2
+
3
+ export default Textarea;
@@ -0,0 +1,121 @@
1
+ import React, { useMemo } from "react";
2
+ // import { cn } from "@/utils/cn";
3
+ import { cn } from "../../utils/cn";
4
+ import AnimatedItem from "../../notion-ui/animated-item";
5
+ // import AnimatedItem from "../animated-item";
6
+
7
+ export interface TextareaProps
8
+ extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
9
+ requiredHint?: string;
10
+ label?: string;
11
+ errorMessage?: string;
12
+ classNames?: {
13
+ rootDivClassName?: string;
14
+ };
15
+ }
16
+
17
+ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
18
+ (
19
+ {
20
+ className,
21
+ requiredHint,
22
+ classNames,
23
+ errorMessage,
24
+ label,
25
+ readOnly,
26
+ ...rest
27
+ },
28
+ ref
29
+ ) => {
30
+ const hasError = !!errorMessage;
31
+ const { rootDivClassName } = classNames || {};
32
+ const heightStyle = {
33
+ required: label ? "ltr:top-[4px] rtl:top-[12px]" : "top-[-19px]",
34
+ };
35
+ const readOnlyStyle = readOnly && "opacity-40";
36
+
37
+ return (
38
+ <div
39
+ className={cn(
40
+ rootDivClassName,
41
+ "flex w-full flex-col justify-end",
42
+ readOnlyStyle
43
+ )}
44
+ >
45
+ <div
46
+ className={cn(
47
+ "relative text-start select-none h-fit rtl:text-lg-rtl ltr:text-lg-ltr"
48
+ )}
49
+ >
50
+ {/* Required Hint */}
51
+ {requiredHint && (
52
+ <span
53
+ className={cn(
54
+ "absolute font-semibold text-red-600 rtl:text-[13px] ltr:text-[11px] ltr:right-2.5 rtl:left-2.5",
55
+ heightStyle.required
56
+ )}
57
+ >
58
+ {requiredHint}
59
+ </span>
60
+ )}
61
+
62
+ {/* Label */}
63
+ {label && (
64
+ <label
65
+ htmlFor={label}
66
+ className={cn(
67
+ "font-semibold rtl:text-xl-rtl ltr:text-lg-ltr inline-block pb-1"
68
+ )}
69
+ >
70
+ {label}
71
+ </label>
72
+ )}
73
+
74
+ {/* Textarea Field */}
75
+
76
+ <textarea
77
+ ref={ref}
78
+ data-slot="textarea"
79
+ readOnly={readOnly}
80
+ className={cn(
81
+ "border-input placeholder:text-muted-foreground focus-visible:border-ring-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-70 md:text-sm focus-visible:border-tertiary/60 focus-visible:shadow-sm",
82
+ "placeholder:text-primary/60 ltr:text-sm rtl:text-sm rtl:font-semibold",
83
+ hasError && "border-red-400",
84
+ className
85
+ )}
86
+ {...rest}
87
+ disabled={readOnly}
88
+ />
89
+ </div>
90
+
91
+ {/* Error Message */}
92
+ {hasError && (
93
+ <AnimatedItem
94
+ springProps={{
95
+ from: {
96
+ opacity: 0,
97
+ transform: "translateY(-8px)",
98
+ },
99
+ config: {
100
+ mass: 1,
101
+ tension: 210,
102
+ friction: 20,
103
+ },
104
+ to: {
105
+ opacity: 1,
106
+ transform: "translateY(0px)",
107
+ },
108
+ }}
109
+ intersectionArgs={{ once: true, rootMargin: "-5% 0%" }}
110
+ >
111
+ <h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-sm-ltr">
112
+ {errorMessage}
113
+ </h1>
114
+ </AnimatedItem>
115
+ )}
116
+ </div>
117
+ );
118
+ }
119
+ );
120
+
121
+ export default Textarea;