notionsoft-ui 1.0.34 → 1.0.36
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/.storybook/main.ts +19 -13
- package/.storybook/preview.css +0 -16
- package/package.json +7 -2
- package/src/notion-ui/animated-item/animated-item.tsx +1 -1
- package/src/notion-ui/animated-item/index.ts +1 -1
- package/src/notion-ui/button/Button.stories.tsx +31 -8
- package/src/notion-ui/button/button.tsx +10 -2
- package/src/notion-ui/button-spinner/ButtonSpinner.stories.tsx +42 -34
- package/src/notion-ui/button-spinner/button-spinner.tsx +4 -5
- package/src/notion-ui/cached-image/cached-image.stories.tsx +109 -0
- package/src/notion-ui/cached-image/cached-image.tsx +213 -0
- package/src/notion-ui/cached-image/index.ts +3 -0
- package/src/notion-ui/cached-image/utils.ts +7 -0
- package/src/notion-ui/cached-svg/CachedSvg.stories.tsx +74 -0
- package/src/notion-ui/cached-svg/cached-svg.tsx +150 -0
- package/src/notion-ui/cached-svg/index.ts +3 -0
- package/src/notion-ui/cached-svg/utils.ts +7 -0
- package/src/notion-ui/date-picker/DatePicker.stories.tsx +0 -2
- package/src/notion-ui/date-picker/date-picker.tsx +5 -5
- package/src/notion-ui/input/Input.stories.tsx +1 -1
- package/src/notion-ui/input/input.tsx +5 -4
- package/src/notion-ui/multi-date-picker/multi-date-picker.tsx +5 -5
- package/src/notion-ui/page-size-select/page-size-select.tsx +29 -12
- package/src/notion-ui/password-input/password-input.tsx +3 -3
- package/src/notion-ui/phone-input/phone-input.tsx +38 -8
- package/src/notion-ui/shimmer/shimmer.tsx +9 -3
- package/src/notion-ui/shining-text/shining-text.tsx +2 -6
- package/src/notion-ui/sidebar/index.ts +3 -0
- package/src/notion-ui/sidebar/sidebar-item.tsx +198 -0
- package/src/notion-ui/sidebar/sidebar.stories.tsx +181 -0
- package/src/notion-ui/sidebar/sidebar.tsx +284 -0
- package/src/notion-ui/textarea/Textarea.stories.tsx +1 -1
- package/src/notion-ui/textarea/textarea.tsx +3 -3
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
|
|
3
|
+
import CachedSvg from "./cached-svg";
|
|
4
|
+
|
|
5
|
+
/* ---------------------------------- */
|
|
6
|
+
/* Meta */
|
|
7
|
+
/* ---------------------------------- */
|
|
8
|
+
const meta: Meta<typeof CachedSvg> = {
|
|
9
|
+
title: "Media/CachedSvg",
|
|
10
|
+
component: CachedSvg,
|
|
11
|
+
tags: ["autodocs"],
|
|
12
|
+
argTypes: {
|
|
13
|
+
className: { control: "text" },
|
|
14
|
+
classNames: { control: false },
|
|
15
|
+
src: { control: "text" },
|
|
16
|
+
fetch: { control: false },
|
|
17
|
+
apiConfig: { control: false },
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
|
|
23
|
+
type Story = StoryObj<typeof CachedSvg>;
|
|
24
|
+
|
|
25
|
+
/* ---------------------------------- */
|
|
26
|
+
/* Sample SVG string */
|
|
27
|
+
/* ---------------------------------- */
|
|
28
|
+
const sampleSvg = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
29
|
+
<circle cx="12" cy="12" r="10" fill="currentColor"/>
|
|
30
|
+
</svg>`;
|
|
31
|
+
|
|
32
|
+
/* ---------------------------------- */
|
|
33
|
+
/* Stories */
|
|
34
|
+
/* ---------------------------------- */
|
|
35
|
+
|
|
36
|
+
/* -------- Fetch-based example -------- */
|
|
37
|
+
export const FetchExample: Story = {
|
|
38
|
+
render: () => (
|
|
39
|
+
<div style={{ width: 40, height: 40 }}>
|
|
40
|
+
<CachedSvg
|
|
41
|
+
src="https://dummy-svg-url.com/sample.svg"
|
|
42
|
+
fetch={async () => {
|
|
43
|
+
return new Response(sampleSvg, { status: 200 });
|
|
44
|
+
}}
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/* -------- API-config-based example -------- */
|
|
51
|
+
export const ApiConfigExample: Story = {
|
|
52
|
+
render: () => (
|
|
53
|
+
<div style={{ width: 40, height: 40 }}>
|
|
54
|
+
<CachedSvg
|
|
55
|
+
apiConfig={{
|
|
56
|
+
src: "https://dummy-api.com/svg",
|
|
57
|
+
headers: { Authorization: "Bearer token" },
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/* -------- Loading state (Shimmer) -------- */
|
|
65
|
+
export const LoadingState: Story = {
|
|
66
|
+
render: () => (
|
|
67
|
+
<div style={{ width: 40, height: 40 }}>
|
|
68
|
+
<CachedSvg
|
|
69
|
+
src="https://dummy-loading.com/svg"
|
|
70
|
+
fetch={() => new Promise(() => {})} // never resolves to simulate loading
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
),
|
|
74
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import React, { forwardRef, useEffect, useRef, useState } from "react";
|
|
2
|
+
import Shimmer from "../shimmer";
|
|
3
|
+
import { cn } from "../../utils/cn";
|
|
4
|
+
import DOMPurify from "dompurify";
|
|
5
|
+
|
|
6
|
+
/* ---------------------------------- */
|
|
7
|
+
/* Types */
|
|
8
|
+
/* ---------------------------------- */
|
|
9
|
+
|
|
10
|
+
interface BaseSvgProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
11
|
+
classNames?: {
|
|
12
|
+
shimmerClassName?: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FetchSvgProps extends BaseSvgProps {
|
|
17
|
+
src: string;
|
|
18
|
+
fetch: (src: string) => Promise<Response>;
|
|
19
|
+
apiConfig?: never;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ApiConfigSvgProps extends BaseSvgProps {
|
|
23
|
+
apiConfig: {
|
|
24
|
+
src: string;
|
|
25
|
+
headers?: Record<string, string>;
|
|
26
|
+
};
|
|
27
|
+
src?: never;
|
|
28
|
+
fetch?: never;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type CachedSvgProps = FetchSvgProps | ApiConfigSvgProps;
|
|
32
|
+
|
|
33
|
+
/* ---------------------------------- */
|
|
34
|
+
/* Cache */
|
|
35
|
+
/* ---------------------------------- */
|
|
36
|
+
|
|
37
|
+
const SVG_CACHE = "svg-cache-v1";
|
|
38
|
+
|
|
39
|
+
/* ---------------------------------- */
|
|
40
|
+
/* Sanitizer */
|
|
41
|
+
/* ---------------------------------- */
|
|
42
|
+
|
|
43
|
+
function sanitizeSvg(svg: string): string {
|
|
44
|
+
return DOMPurify.sanitize(svg, {
|
|
45
|
+
USE_PROFILES: { svg: true, svgFilters: true },
|
|
46
|
+
FORBID_TAGS: ["script", "foreignObject", "iframe", "object", "embed"],
|
|
47
|
+
FORBID_ATTR: [
|
|
48
|
+
"onload",
|
|
49
|
+
"onclick",
|
|
50
|
+
"onmouseover",
|
|
51
|
+
"onerror",
|
|
52
|
+
"href",
|
|
53
|
+
"xlink:href",
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ---------------------------------- */
|
|
59
|
+
/* Component */
|
|
60
|
+
/* ---------------------------------- */
|
|
61
|
+
|
|
62
|
+
const CachedSvg = forwardRef<HTMLDivElement, CachedSvgProps>(
|
|
63
|
+
({ className, classNames, fetch, apiConfig, ...rest }, ref) => {
|
|
64
|
+
const [svg, setSvg] = useState<string | null>(null);
|
|
65
|
+
const [loading, setLoading] = useState(true);
|
|
66
|
+
|
|
67
|
+
const fetchRef = useRef(fetch);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
fetchRef.current = fetch;
|
|
70
|
+
}, [fetch]);
|
|
71
|
+
|
|
72
|
+
async function loadSvg() {
|
|
73
|
+
try {
|
|
74
|
+
const src = apiConfig?.src ?? (rest as any).src;
|
|
75
|
+
if (!src) return;
|
|
76
|
+
|
|
77
|
+
/* ---------------- Cache ---------------- */
|
|
78
|
+
|
|
79
|
+
if ("caches" in window) {
|
|
80
|
+
const cache = await caches.open(SVG_CACHE);
|
|
81
|
+
const cached = await cache.match(src);
|
|
82
|
+
|
|
83
|
+
if (cached) {
|
|
84
|
+
const text = await cached.text();
|
|
85
|
+
setSvg(sanitizeSvg(text));
|
|
86
|
+
setLoading(false);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ---------------- Fetch ---------------- */
|
|
92
|
+
|
|
93
|
+
const response = fetchRef.current
|
|
94
|
+
? await fetchRef.current(src)
|
|
95
|
+
: await window.fetch(src, { headers: apiConfig?.headers });
|
|
96
|
+
|
|
97
|
+
if (!response.ok) throw new Error("SVG fetch failed");
|
|
98
|
+
|
|
99
|
+
const clone = response.clone();
|
|
100
|
+
const text = await response.text();
|
|
101
|
+
|
|
102
|
+
setSvg(sanitizeSvg(text));
|
|
103
|
+
|
|
104
|
+
if ("caches" in window) {
|
|
105
|
+
const cache = await caches.open(SVG_CACHE);
|
|
106
|
+
await cache.put(src, clone);
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error(err);
|
|
110
|
+
} finally {
|
|
111
|
+
setLoading(false);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
loadSvg();
|
|
117
|
+
}, [apiConfig?.src, (rest as any).src]);
|
|
118
|
+
|
|
119
|
+
/* ---------------- UI ---------------- */
|
|
120
|
+
const iconStyle = "opacity-90 rounded-full w-[20px] h-[18px]";
|
|
121
|
+
if (loading || !svg) {
|
|
122
|
+
return (
|
|
123
|
+
<Shimmer
|
|
124
|
+
className={cn(
|
|
125
|
+
"bg-primary/10",
|
|
126
|
+
iconStyle,
|
|
127
|
+
classNames?.shimmerClassName
|
|
128
|
+
)}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
ref={ref}
|
|
136
|
+
className={cn(
|
|
137
|
+
"[&>svg>path]:fill-current [&>svg>g>*]:fill-current items-center justify-center flex",
|
|
138
|
+
iconStyle,
|
|
139
|
+
className
|
|
140
|
+
)}
|
|
141
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
142
|
+
{...rest}
|
|
143
|
+
/>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
CachedSvg.displayName = "CachedSvg";
|
|
149
|
+
|
|
150
|
+
export default CachedSvg;
|
|
@@ -42,7 +42,6 @@ export const Default: StoryObj<DatePickerProps> = {
|
|
|
42
42
|
render: (args) => <Wrapper {...args} />,
|
|
43
43
|
args: {
|
|
44
44
|
placeholder: "Select date...",
|
|
45
|
-
required: false,
|
|
46
45
|
label: "Date",
|
|
47
46
|
measurement: "md",
|
|
48
47
|
},
|
|
@@ -83,7 +82,6 @@ export const WithError: StoryObj<DatePickerProps> = {
|
|
|
83
82
|
placeholder: "Select date...",
|
|
84
83
|
label: "Birthday",
|
|
85
84
|
errorMessage: "This field is required",
|
|
86
|
-
required: true,
|
|
87
85
|
requiredHint: "*",
|
|
88
86
|
},
|
|
89
87
|
};
|
|
@@ -212,7 +212,7 @@ export default function DatePicker(props: DatePickerProps) {
|
|
|
212
212
|
<label
|
|
213
213
|
htmlFor={label}
|
|
214
214
|
className={cn(
|
|
215
|
-
"font-semibold
|
|
215
|
+
"font-semibold ltr:text-[13px] rtl:text-[18px] inline-block pb-1"
|
|
216
216
|
)}
|
|
217
217
|
>
|
|
218
218
|
{label}
|
|
@@ -224,18 +224,18 @@ export default function DatePicker(props: DatePickerProps) {
|
|
|
224
224
|
height: heightStyle.height,
|
|
225
225
|
}}
|
|
226
226
|
className={cn(
|
|
227
|
-
"flex items-center text-start px-3 border select-none rounded-sm rtl:text-
|
|
227
|
+
"flex items-center text-start px-3 border select-none rounded-sm rtl:text-[17px] ltr:text-[13px]",
|
|
228
228
|
className
|
|
229
229
|
)}
|
|
230
230
|
onClick={onVisibilityChange}
|
|
231
231
|
>
|
|
232
232
|
{selectedDates ? (
|
|
233
|
-
<h1 className="flex items-center gap-x-2 text-ellipsis rtl:text-
|
|
233
|
+
<h1 className="flex items-center gap-x-2 text-ellipsis rtl:text-[17px] ltr:text-[13px] text-primary/80 whitespace-nowrap overflow-hidden">
|
|
234
234
|
<CalendarDays className="size-4 inline-block text-tertiary rtl:ml-2 rtl:mr-2" />
|
|
235
235
|
{formatHijriDate(selectedDates)}
|
|
236
236
|
</h1>
|
|
237
237
|
) : (
|
|
238
|
-
<h1 className="flex items-center gap-x-2 text-ellipsis rtl:text-
|
|
238
|
+
<h1 className="flex items-center gap-x-2 text-ellipsis rtl:text-[17px] ltr:text-[13px] font-semibold text-primary whitespace-nowrap overflow-hidden">
|
|
239
239
|
<CalendarDays className="size-4 inline-block text-tertiary" />
|
|
240
240
|
{placeholder}
|
|
241
241
|
</h1>
|
|
@@ -261,7 +261,7 @@ export default function DatePicker(props: DatePickerProps) {
|
|
|
261
261
|
}}
|
|
262
262
|
intersectionArgs={{ once: true, rootMargin: "-5% 0%" }}
|
|
263
263
|
>
|
|
264
|
-
<h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-
|
|
264
|
+
<h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-[11px]">
|
|
265
265
|
{errorMessage}
|
|
266
266
|
</h1>
|
|
267
267
|
</AnimatedItem>
|
|
@@ -19,7 +19,7 @@ export interface InputProps
|
|
|
19
19
|
measurement?: NastranInputSize;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
23
23
|
(
|
|
24
24
|
{
|
|
25
25
|
className,
|
|
@@ -86,12 +86,13 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
|
86
86
|
className={cn(
|
|
87
87
|
rootDivClassName,
|
|
88
88
|
"flex w-full flex-col justify-end",
|
|
89
|
+
requiredHint && !label && "mt-5",
|
|
89
90
|
readOnlyStyle
|
|
90
91
|
)}
|
|
91
92
|
>
|
|
92
93
|
<div
|
|
93
94
|
className={cn(
|
|
94
|
-
"relative text-start select-none h-fit
|
|
95
|
+
"relative text-start select-none h-fit ltr:text-[13px] rtl:text-[18px]"
|
|
95
96
|
)}
|
|
96
97
|
>
|
|
97
98
|
{/* Start Content */}
|
|
@@ -135,7 +136,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
|
135
136
|
<label
|
|
136
137
|
htmlFor={label}
|
|
137
138
|
className={cn(
|
|
138
|
-
"font-semibold
|
|
139
|
+
"font-semibold ltr:text-[13px] rtl:text-[18px] inline-block pb-1"
|
|
139
140
|
)}
|
|
140
141
|
>
|
|
141
142
|
{label}
|
|
@@ -189,7 +190,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
|
189
190
|
}}
|
|
190
191
|
intersectionArgs={{ once: true, rootMargin: "-5% 0%" }}
|
|
191
192
|
>
|
|
192
|
-
<h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-
|
|
193
|
+
<h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-[11px]">
|
|
193
194
|
{errorMessage}
|
|
194
195
|
</h1>
|
|
195
196
|
</AnimatedItem>
|
|
@@ -195,7 +195,7 @@ export default function MultiDatePicker(props: MultiDatePickerProps) {
|
|
|
195
195
|
<label
|
|
196
196
|
htmlFor={label}
|
|
197
197
|
className={cn(
|
|
198
|
-
"font-semibold
|
|
198
|
+
"font-semibold ltr:text-[13px] rtl:text-[18px] inline-block pb-1"
|
|
199
199
|
)}
|
|
200
200
|
>
|
|
201
201
|
{label}
|
|
@@ -206,13 +206,13 @@ export default function MultiDatePicker(props: MultiDatePickerProps) {
|
|
|
206
206
|
height: heightStyle.height,
|
|
207
207
|
}}
|
|
208
208
|
className={cn(
|
|
209
|
-
"relative flex items-center text-start px-3 border select-none rounded-sm rtl:text-
|
|
209
|
+
"relative flex items-center text-start px-3 border select-none rounded-sm rtl:text-[17px] ltr:text-[13px]",
|
|
210
210
|
className
|
|
211
211
|
)}
|
|
212
212
|
onClick={onVisibilityChange}
|
|
213
213
|
>
|
|
214
214
|
{selectedDates && selectedDates.length > 0 ? (
|
|
215
|
-
<div className="flex items-center gap-x-2 text-ellipsis rtl:text-
|
|
215
|
+
<div className="flex items-center gap-x-2 text-ellipsis rtl:text-[17px] ltr:text-[13px] text-primary/80 text-nowrap">
|
|
216
216
|
<CalendarDays className="size-4 inline-block text-tertiary rtl:ml-2 rtl:mr-2" />
|
|
217
217
|
{selectedDates.map((date: DateObject, index: number) => (
|
|
218
218
|
<div key={index} className="flex gap-x-2">
|
|
@@ -229,7 +229,7 @@ export default function MultiDatePicker(props: MultiDatePickerProps) {
|
|
|
229
229
|
))}
|
|
230
230
|
</div>
|
|
231
231
|
) : (
|
|
232
|
-
<h1 className="flex items-center gap-x-2 text-ellipsis rtl:text-
|
|
232
|
+
<h1 className="flex items-center gap-x-2 text-ellipsis rtl:text-[17px] ltr:text-[13px] text-primary/80 text-nowrap">
|
|
233
233
|
<CalendarDays className="size-4 inline-block text-tertiary" />
|
|
234
234
|
{placeholder}
|
|
235
235
|
</h1>
|
|
@@ -255,7 +255,7 @@ export default function MultiDatePicker(props: MultiDatePickerProps) {
|
|
|
255
255
|
}}
|
|
256
256
|
intersectionArgs={{ once: true, rootMargin: "-5% 0%" }}
|
|
257
257
|
>
|
|
258
|
-
<h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-
|
|
258
|
+
<h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-[11px]">
|
|
259
259
|
{errorMessage}
|
|
260
260
|
</h1>
|
|
261
261
|
</AnimatedItem>
|
|
@@ -107,27 +107,44 @@ const PageSizeSelect: React.FC<SelectProps> = ({
|
|
|
107
107
|
|
|
108
108
|
const rect = trigger.getBoundingClientRect();
|
|
109
109
|
const viewportHeight = window.innerHeight;
|
|
110
|
+
const viewportWidth = window.innerWidth;
|
|
110
111
|
const gap = 6;
|
|
111
112
|
|
|
112
113
|
const dropdownHeight = Math.min(dropdown.offsetHeight || 0, 260);
|
|
114
|
+
const dropdownWidth = dropdown.offsetWidth || rect.width;
|
|
115
|
+
|
|
113
116
|
const spaceBelow = viewportHeight - rect.bottom;
|
|
114
117
|
const spaceAbove = rect.top;
|
|
115
118
|
|
|
119
|
+
const spaceRight = viewportWidth - rect.left;
|
|
120
|
+
const spaceLeft = rect.right;
|
|
121
|
+
|
|
122
|
+
/* ---------- Vertical (Up / Down) ---------- */
|
|
123
|
+
let top: number;
|
|
116
124
|
if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) {
|
|
117
125
|
setDropDirection("up");
|
|
118
|
-
|
|
119
|
-
top: rect.top + window.scrollY - dropdownHeight - gap,
|
|
120
|
-
left: rect.left + window.scrollX,
|
|
121
|
-
width: rect.width,
|
|
122
|
-
});
|
|
126
|
+
top = rect.top + window.scrollY - dropdownHeight - gap;
|
|
123
127
|
} else {
|
|
124
128
|
setDropDirection("down");
|
|
125
|
-
|
|
126
|
-
top: rect.bottom + window.scrollY + gap,
|
|
127
|
-
left: rect.left + window.scrollX,
|
|
128
|
-
width: rect.width,
|
|
129
|
-
});
|
|
129
|
+
top = rect.bottom + window.scrollY + gap;
|
|
130
130
|
}
|
|
131
|
+
|
|
132
|
+
/* ---------- Horizontal (Left / Right) ---------- */
|
|
133
|
+
let left = rect.left + window.scrollX;
|
|
134
|
+
|
|
135
|
+
// If dropdown overflows right viewport → shift left
|
|
136
|
+
if (spaceRight < dropdownWidth && spaceLeft >= dropdownWidth) {
|
|
137
|
+
left = rect.right + window.scrollX - dropdownWidth;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Clamp to viewport (safety)
|
|
141
|
+
left = Math.max(8, Math.min(left, viewportWidth - dropdownWidth - 8));
|
|
142
|
+
|
|
143
|
+
setPosition({
|
|
144
|
+
top,
|
|
145
|
+
left,
|
|
146
|
+
width: rect.width,
|
|
147
|
+
});
|
|
131
148
|
};
|
|
132
149
|
|
|
133
150
|
useLayoutEffect(() => {
|
|
@@ -171,7 +188,7 @@ const PageSizeSelect: React.FC<SelectProps> = ({
|
|
|
171
188
|
<div ref={selectRef} className={cn("w-full", className)}>
|
|
172
189
|
<button
|
|
173
190
|
onClick={() => setSelectData((p) => ({ ...p, isOpen: !p.isOpen }))}
|
|
174
|
-
className="w-full py-2 border rounded-md flex items-center justify-between bg-card"
|
|
191
|
+
className="w-full px-3 py-2 border rounded-md flex items-center justify-between bg-card"
|
|
175
192
|
>
|
|
176
193
|
{selectData.select.value || placeholder}
|
|
177
194
|
<ChevronDown
|
|
@@ -209,7 +226,7 @@ const PageSizeSelect: React.FC<SelectProps> = ({
|
|
|
209
226
|
? selectData.select.value
|
|
210
227
|
: ""
|
|
211
228
|
}
|
|
212
|
-
className={`bg-card dark:bg-card-secondary text-tertiary rtl:text-
|
|
229
|
+
className={`bg-card dark:bg-card-secondary text-tertiary rtl:text-[17px] w-full [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none text-center text-sm px-4 py-2 border-b border-primary/15 rounded-t-md focus:outline-none`}
|
|
213
230
|
/>
|
|
214
231
|
<Check
|
|
215
232
|
className={cn(
|
|
@@ -93,7 +93,7 @@ const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
|
|
93
93
|
{/* Password strength text */}
|
|
94
94
|
<p
|
|
95
95
|
id="password-strength"
|
|
96
|
-
className="mb-2 text-start rtl:text-
|
|
96
|
+
className="mb-2 text-start rtl:text-lg ltr:text-sm font-medium text-foreground"
|
|
97
97
|
>
|
|
98
98
|
{`${getStrengthText(strengthScore)}. ${text.must_contain}`}
|
|
99
99
|
</p>
|
|
@@ -108,9 +108,9 @@ const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
|
|
108
108
|
<X size={16} className="text-muted-foreground/80" />
|
|
109
109
|
)}
|
|
110
110
|
<span
|
|
111
|
-
className={`ltr:text-xs rtl:text-
|
|
111
|
+
className={`ltr:text-xs rtl:text-[17px] ${
|
|
112
112
|
req.met
|
|
113
|
-
? "text-emerald-600 ltr:text-
|
|
113
|
+
? "text-emerald-600 ltr:text-sm"
|
|
114
114
|
: "text-muted-foreground"
|
|
115
115
|
}`}
|
|
116
116
|
>
|
|
@@ -78,6 +78,9 @@ interface PhoneInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
|
78
78
|
rootDivClassName?: string;
|
|
79
79
|
iconClassName?: string;
|
|
80
80
|
};
|
|
81
|
+
text: {
|
|
82
|
+
searchInputPlaceholder: string;
|
|
83
|
+
};
|
|
81
84
|
measurement?: PhoneInputSize;
|
|
82
85
|
ROW_HEIGHT?: number;
|
|
83
86
|
VISIBLE_ROWS?: number;
|
|
@@ -97,11 +100,14 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
|
|
|
97
100
|
ROW_HEIGHT = 32,
|
|
98
101
|
VISIBLE_ROWS = 10,
|
|
99
102
|
BUFFER = 5,
|
|
103
|
+
text,
|
|
100
104
|
...rest
|
|
101
105
|
}) => {
|
|
102
106
|
const { rootDivClassName, iconClassName = "size-4" } = classNames || {};
|
|
103
107
|
const [open, setOpen] = useState(false);
|
|
104
108
|
const [highlightedIndex, setHighlightedIndex] = useState<number>(0);
|
|
109
|
+
const { searchInputPlaceholder } = text;
|
|
110
|
+
|
|
105
111
|
const initialCountry = (() => {
|
|
106
112
|
if (typeof value === "string" && value.startsWith("+")) {
|
|
107
113
|
const matched = defaultCountries.find((c) =>
|
|
@@ -119,6 +125,20 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
|
|
|
119
125
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
120
126
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
121
127
|
const [position, setPosition] = useState({ top: 0, left: 0, width: 0 });
|
|
128
|
+
const [search, setSearch] = useState("");
|
|
129
|
+
const filteredCountries = useMemo(() => {
|
|
130
|
+
if (!search.trim()) return defaultCountries;
|
|
131
|
+
const s = search.toLowerCase();
|
|
132
|
+
return defaultCountries.filter(
|
|
133
|
+
(c) =>
|
|
134
|
+
c.name.toLowerCase().includes(s) ||
|
|
135
|
+
c.iso2.toLowerCase().includes(s) ||
|
|
136
|
+
("+" + c.dialCode).includes(s)
|
|
137
|
+
);
|
|
138
|
+
}, [search]);
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
setHighlightedIndex(0);
|
|
141
|
+
}, [search]);
|
|
122
142
|
|
|
123
143
|
const [dropDirection, setDropDirection] = useState<"down" | "up">("down");
|
|
124
144
|
|
|
@@ -163,14 +183,14 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
|
|
|
163
183
|
|
|
164
184
|
if (e.key === "ArrowDown") {
|
|
165
185
|
setHighlightedIndex((prev) =>
|
|
166
|
-
Math.min(prev + 1,
|
|
186
|
+
Math.min(prev + 1, filteredCountries.length - 1)
|
|
167
187
|
);
|
|
168
188
|
e.preventDefault();
|
|
169
189
|
} else if (e.key === "ArrowUp") {
|
|
170
190
|
setHighlightedIndex((prev) => Math.max(prev - 1, 0));
|
|
171
191
|
e.preventDefault();
|
|
172
192
|
} else if (e.key === "Enter") {
|
|
173
|
-
chooseCountry(
|
|
193
|
+
chooseCountry(filteredCountries[highlightedIndex]);
|
|
174
194
|
e.preventDefault();
|
|
175
195
|
} else if (e.key === "Escape") {
|
|
176
196
|
setOpen(false);
|
|
@@ -247,7 +267,7 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
|
|
|
247
267
|
}
|
|
248
268
|
};
|
|
249
269
|
|
|
250
|
-
useLayoutEffect(() => updateDropdownPosition(), [open]);
|
|
270
|
+
useLayoutEffect(() => updateDropdownPosition(), [open, search]);
|
|
251
271
|
useEffect(() => {
|
|
252
272
|
if (!open) return;
|
|
253
273
|
window.addEventListener("resize", updateDropdownPosition);
|
|
@@ -357,6 +377,7 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
|
|
|
357
377
|
+{country.dialCode}
|
|
358
378
|
</span>
|
|
359
379
|
</button>
|
|
380
|
+
|
|
360
381
|
<input
|
|
361
382
|
ref={inputRef}
|
|
362
383
|
type="tel"
|
|
@@ -377,16 +398,14 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
|
|
|
377
398
|
"focus-visible:border-tertiary/60",
|
|
378
399
|
"[&::-webkit-outer-spin-button]:appearance-none",
|
|
379
400
|
"[&::-webkit-inner-spin-button]:appearance-none",
|
|
380
|
-
"[-moz-appearance:textfield] ",
|
|
401
|
+
"[-moz-appearance:textfield] rtl:text-right",
|
|
381
402
|
hasError && "border-red-400",
|
|
382
403
|
className
|
|
383
404
|
)}
|
|
384
405
|
{...rest}
|
|
385
406
|
disabled={readOnly}
|
|
386
|
-
dir="ltr"
|
|
387
407
|
/>
|
|
388
408
|
</div>
|
|
389
|
-
|
|
390
409
|
{open &&
|
|
391
410
|
createPortal(
|
|
392
411
|
<div
|
|
@@ -403,10 +422,21 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
|
|
|
403
422
|
}}
|
|
404
423
|
role="listbox"
|
|
405
424
|
>
|
|
425
|
+
{/* 🔍 Search bar */}
|
|
426
|
+
<div className="p-2 border-b bg-card sticky top-0 z-10">
|
|
427
|
+
<input
|
|
428
|
+
type="text"
|
|
429
|
+
autoFocus
|
|
430
|
+
className="w-full px-2 py-1 text-sm border rounded-sm bg-input/30 focus:outline-none"
|
|
431
|
+
placeholder={searchInputPlaceholder}
|
|
432
|
+
value={search}
|
|
433
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
434
|
+
/>
|
|
435
|
+
</div>
|
|
406
436
|
<VirtualList
|
|
407
437
|
ROW_HEIGHT={ROW_HEIGHT}
|
|
408
438
|
BUFFER={BUFFER}
|
|
409
|
-
items={
|
|
439
|
+
items={filteredCountries}
|
|
410
440
|
height={ROW_HEIGHT * VISIBLE_ROWS}
|
|
411
441
|
renderRow={(c, i) => (
|
|
412
442
|
<div
|
|
@@ -446,7 +476,7 @@ const PhoneInput: React.FC<PhoneInputProps> = ({
|
|
|
446
476
|
}}
|
|
447
477
|
intersectionArgs={{ once: true, rootMargin: "-5% 0%" }}
|
|
448
478
|
>
|
|
449
|
-
<h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-
|
|
479
|
+
<h1 className="text-red-400 text-start capitalize rtl:text-sm rtl:font-medium ltr:text-[11px]">
|
|
450
480
|
{errorMessage}
|
|
451
481
|
</h1>
|
|
452
482
|
</AnimatedItem>
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { cn } from "../../utils/cn";
|
|
2
2
|
|
|
3
|
-
export interface ShimmerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
3
|
+
export interface ShimmerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
4
|
+
stop?: boolean;
|
|
5
|
+
}
|
|
4
6
|
|
|
5
|
-
export default function Shimmer({
|
|
7
|
+
export default function Shimmer({
|
|
8
|
+
stop = false,
|
|
9
|
+
className,
|
|
10
|
+
children,
|
|
11
|
+
}: ShimmerProps) {
|
|
6
12
|
return (
|
|
7
13
|
<div
|
|
8
14
|
className={cn("relative w-full overflow-hidden *:rounded-sm", className)}
|
|
@@ -30,7 +36,7 @@ export default function Shimmer({ className, children }: ShimmerProps) {
|
|
|
30
36
|
var(--from-shimmer) 25%
|
|
31
37
|
)`,
|
|
32
38
|
backgroundSize: "1200px 100%",
|
|
33
|
-
animation: "shimmer 2.2s linear infinite",
|
|
39
|
+
animation: !stop ? "shimmer 2.2s linear infinite" : "",
|
|
34
40
|
}}
|
|
35
41
|
/>
|
|
36
42
|
|
|
@@ -7,11 +7,7 @@ interface ShiningTextProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
|
7
7
|
text: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export
|
|
11
|
-
text,
|
|
12
|
-
className,
|
|
13
|
-
...props
|
|
14
|
-
}: ShiningTextProps) {
|
|
10
|
+
export function ShiningText({ text, className, ...props }: ShiningTextProps) {
|
|
15
11
|
// Animate strictly left → right
|
|
16
12
|
const styles = useSpring({
|
|
17
13
|
from: { backgroundPosition: "-100% 0%" }, // start offscreen left
|
|
@@ -27,7 +23,7 @@ export default function ShiningText({
|
|
|
27
23
|
...styles,
|
|
28
24
|
}}
|
|
29
25
|
className={cn(
|
|
30
|
-
"bg-gradient-to-r
|
|
26
|
+
"bg-gradient-to-r text-md font-medium from-black via-gray-100 to-black", // left→right gradient
|
|
31
27
|
"bg-clip-text text-transparent",
|
|
32
28
|
className
|
|
33
29
|
)}
|