notionsoft-ui 1.0.19 → 1.0.20
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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import PasswordInput, { PasswordInputProps } from "./password-input";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<PasswordInputProps> = {
|
|
5
|
+
title: "Form/PasswordInput",
|
|
6
|
+
component: PasswordInput,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
argTypes: {
|
|
9
|
+
defaultValue: {
|
|
10
|
+
control: "text",
|
|
11
|
+
description: "Initial password value",
|
|
12
|
+
},
|
|
13
|
+
parentClassName: {
|
|
14
|
+
control: "text",
|
|
15
|
+
description: "Wrapper className",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default meta;
|
|
21
|
+
type Story = StoryObj<PasswordInputProps>;
|
|
22
|
+
|
|
23
|
+
export const Default: Story = {
|
|
24
|
+
args: {
|
|
25
|
+
placeholder: "Enter password...",
|
|
26
|
+
parentClassName: "",
|
|
27
|
+
text: {
|
|
28
|
+
strong_password: "Strong password",
|
|
29
|
+
enter_password: "Enter a password",
|
|
30
|
+
weak_password: "Weak password",
|
|
31
|
+
medium_password: "Medium strength",
|
|
32
|
+
must_contain: "Your password must contain:",
|
|
33
|
+
at_lea_8_char: "At least 8 characters",
|
|
34
|
+
at_lea_1_num: "At least one number",
|
|
35
|
+
at_lea_1_lowcas_lett: "At least one lowercase letter",
|
|
36
|
+
at_lea_1_upcas_lett: "At least one uppercase letter",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const WithDefaultValue: Story = {
|
|
42
|
+
args: {
|
|
43
|
+
placeholder: "Enter password...",
|
|
44
|
+
text: {
|
|
45
|
+
strong_password: "Strong password",
|
|
46
|
+
enter_password: "Enter a password",
|
|
47
|
+
weak_password: "Weak password",
|
|
48
|
+
medium_password: "Medium strength",
|
|
49
|
+
must_contain: "Your password must contain:",
|
|
50
|
+
at_lea_8_char: "At least 8 characters",
|
|
51
|
+
at_lea_1_num: "At least one number",
|
|
52
|
+
at_lea_1_lowcas_lett: "At least one lowercase letter",
|
|
53
|
+
at_lea_1_upcas_lett: "At least one uppercase letter",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import Input from "../input";
|
|
2
|
+
import { InputProps } from "../input/input";
|
|
3
|
+
import { Check, X } from "lucide-react";
|
|
4
|
+
import React, { useMemo, useState } from "react";
|
|
5
|
+
type PasswordInputText = {
|
|
6
|
+
strong_password: string;
|
|
7
|
+
enter_password: string;
|
|
8
|
+
weak_password: string;
|
|
9
|
+
medium_password: string;
|
|
10
|
+
must_contain: string;
|
|
11
|
+
at_lea_8_char: string;
|
|
12
|
+
at_lea_1_num: string;
|
|
13
|
+
at_lea_1_lowcas_lett: string;
|
|
14
|
+
at_lea_1_upcas_lett: string;
|
|
15
|
+
};
|
|
16
|
+
export interface PasswordInputProps extends InputProps {
|
|
17
|
+
text: PasswordInputText;
|
|
18
|
+
}
|
|
19
|
+
const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
|
20
|
+
(props, ref: any) => {
|
|
21
|
+
const { parentClassName, defaultValue, text, onChange, ...rest } = props;
|
|
22
|
+
const [value, setValue] = useState(
|
|
23
|
+
typeof defaultValue === "string" ? defaultValue : ""
|
|
24
|
+
);
|
|
25
|
+
const strength = checkStrength(value, text);
|
|
26
|
+
|
|
27
|
+
const strengthScore = useMemo(() => {
|
|
28
|
+
return passwordStrengthScore(strength);
|
|
29
|
+
}, [strength]);
|
|
30
|
+
|
|
31
|
+
const getStrengthColor = (score: number) => {
|
|
32
|
+
if (score === 0) return "bg-border";
|
|
33
|
+
if (score <= 1) return "bg-red-500";
|
|
34
|
+
if (score <= 2) return "bg-orange-500";
|
|
35
|
+
if (score === 3) return "bg-amber-500";
|
|
36
|
+
return "bg-emerald-500";
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const getStrengthText = (score: number) => {
|
|
40
|
+
if (score === 0) return text.enter_password;
|
|
41
|
+
if (score <= 2) return text.weak_password;
|
|
42
|
+
if (score === 3) return text.medium_password;
|
|
43
|
+
return text.strong_password;
|
|
44
|
+
};
|
|
45
|
+
return (
|
|
46
|
+
<div className={`w-full ${parentClassName}`}>
|
|
47
|
+
<Input
|
|
48
|
+
value={value}
|
|
49
|
+
ref={ref}
|
|
50
|
+
onChange={
|
|
51
|
+
onChange
|
|
52
|
+
? onChange
|
|
53
|
+
: (event: React.ChangeEvent<HTMLInputElement>) =>
|
|
54
|
+
setValue(event.target.value)
|
|
55
|
+
}
|
|
56
|
+
aria-invalid={strengthScore < 4}
|
|
57
|
+
aria-describedby="password-strength"
|
|
58
|
+
{...rest}
|
|
59
|
+
/>
|
|
60
|
+
{/* Password strength indicator */}
|
|
61
|
+
<div
|
|
62
|
+
className="mb-4 mt-3 h-1 w-full overflow-hidden rounded-full bg-border"
|
|
63
|
+
role="progressbar"
|
|
64
|
+
aria-valuenow={strengthScore}
|
|
65
|
+
aria-valuemin={0}
|
|
66
|
+
aria-valuemax={4}
|
|
67
|
+
aria-label="Password strength"
|
|
68
|
+
>
|
|
69
|
+
<div
|
|
70
|
+
className={`h-full ${getStrengthColor(
|
|
71
|
+
strengthScore
|
|
72
|
+
)} transition-all duration-500 ease-out`}
|
|
73
|
+
style={{ width: `${(strengthScore / 4) * 100}%` }}
|
|
74
|
+
></div>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{/* Password strength description */}
|
|
78
|
+
<p
|
|
79
|
+
id="password-strength"
|
|
80
|
+
className="mb-2 text-start rtl:text-xl-rtl ltr:text-xl-ltr font-medium text-foreground"
|
|
81
|
+
>
|
|
82
|
+
{`${getStrengthText(strengthScore)}. ${text.must_contain}`}
|
|
83
|
+
</p>
|
|
84
|
+
|
|
85
|
+
{/* Password requirements list */}
|
|
86
|
+
<ul className="space-y-1.5" aria-label="Password requirements">
|
|
87
|
+
{strength.map((req, index) => (
|
|
88
|
+
<li key={index} className="flex items-center gap-2">
|
|
89
|
+
{req.met ? (
|
|
90
|
+
<Check
|
|
91
|
+
size={16}
|
|
92
|
+
className="text-emerald-500"
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
/>
|
|
95
|
+
) : (
|
|
96
|
+
<X
|
|
97
|
+
size={16}
|
|
98
|
+
className="text-muted-foreground/80"
|
|
99
|
+
aria-hidden="true"
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
<span
|
|
103
|
+
className={`ltr:text-xs rtl:text-lg-rtl ${
|
|
104
|
+
req.met
|
|
105
|
+
? "text-emerald-600 ltr:text-xl-ltr"
|
|
106
|
+
: "text-muted-foreground"
|
|
107
|
+
}`}
|
|
108
|
+
>
|
|
109
|
+
{req.text}
|
|
110
|
+
<span className="sr-only rtl:text-xl-rtl">
|
|
111
|
+
{req.met ? " - Requirement met" : " - Requirement not met"}
|
|
112
|
+
</span>
|
|
113
|
+
</span>
|
|
114
|
+
</li>
|
|
115
|
+
))}
|
|
116
|
+
</ul>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
export const checkStrength = (pass: string, text: PasswordInputText) => {
|
|
123
|
+
const requirements = [
|
|
124
|
+
{ regex: /.{8,}/, text: text.at_lea_8_char },
|
|
125
|
+
{ regex: /[0-9]/, text: text.at_lea_1_num },
|
|
126
|
+
{ regex: /[a-z]/, text: text.at_lea_1_lowcas_lett },
|
|
127
|
+
{ regex: /[A-Z]/, text: text.at_lea_1_upcas_lett },
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
return requirements.map((req) => ({
|
|
131
|
+
met: req.regex.test(pass),
|
|
132
|
+
text: req.text,
|
|
133
|
+
}));
|
|
134
|
+
};
|
|
135
|
+
export const passwordStrengthScore = (
|
|
136
|
+
strength: {
|
|
137
|
+
met: boolean;
|
|
138
|
+
text: any;
|
|
139
|
+
}[]
|
|
140
|
+
): number => strength.filter((req) => req.met).length;
|
|
141
|
+
|
|
142
|
+
export default PasswordInput;
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from "react";
|
|
2
8
|
import { createPortal } from "react-dom";
|
|
3
9
|
import { buildNestedFiltersQuery, cn, useDebounce } from "../../utils/cn";
|
|
4
10
|
import Input from "../input";
|
|
5
|
-
import { Eraser, ListFilter, X } from "lucide-react";
|
|
11
|
+
import { Eraser, ListFilter, LoaderCircle, X } from "lucide-react";
|
|
6
12
|
import CircleLoader from "../circle-loader";
|
|
7
13
|
|
|
8
14
|
export type NastranInputSize = "sm" | "md" | "lg";
|
|
@@ -24,6 +30,7 @@ export interface BaseSearchInputProps<T = { id: string; name: string }>
|
|
|
24
30
|
filters?: FilterItem[];
|
|
25
31
|
onFiltersChange?: (filtersState: Record<string, boolean>) => void;
|
|
26
32
|
debounceValue?: number;
|
|
33
|
+
errorMessage?: string;
|
|
27
34
|
parentClassName?: string;
|
|
28
35
|
text?: {
|
|
29
36
|
fetch?: string;
|
|
@@ -32,6 +39,7 @@ export interface BaseSearchInputProps<T = { id: string; name: string }>
|
|
|
32
39
|
clearFilters?: string;
|
|
33
40
|
};
|
|
34
41
|
endContent?: React.ReactNode;
|
|
42
|
+
startContent?: React.ReactNode;
|
|
35
43
|
STORAGE_KEY?: string;
|
|
36
44
|
}
|
|
37
45
|
|
|
@@ -74,6 +82,8 @@ function SearchInputInner<T = { id: string; name: string }>(
|
|
|
74
82
|
endContent,
|
|
75
83
|
STORAGE_KEY = "FILTER_STORAGE_KEY",
|
|
76
84
|
apiConfig,
|
|
85
|
+
errorMessage,
|
|
86
|
+
startContent,
|
|
77
87
|
itemOnClick,
|
|
78
88
|
...props
|
|
79
89
|
}: SearchInputProps<T>,
|
|
@@ -85,6 +95,7 @@ function SearchInputInner<T = { id: string; name: string }>(
|
|
|
85
95
|
const [isFetching, setIsFetching] = useState(false);
|
|
86
96
|
const [position, setPosition] = useState({ top: 0, left: 0, width: 0 });
|
|
87
97
|
const [items, setItems] = useState<T[]>([]);
|
|
98
|
+
const [shouldFetch, setShouldFetch] = useState(false);
|
|
88
99
|
|
|
89
100
|
const [filtersState, setFiltersState] = useState<Record<string, boolean>>(
|
|
90
101
|
() => {
|
|
@@ -93,6 +104,7 @@ function SearchInputInner<T = { id: string; name: string }>(
|
|
|
93
104
|
return filters.reduce((acc, f) => ({ ...acc, [f.key]: false }), {});
|
|
94
105
|
}
|
|
95
106
|
);
|
|
107
|
+
const [dropDirection, setDropDirection] = useState<"down" | "up">("down");
|
|
96
108
|
|
|
97
109
|
const [maxFetch, setMaxFetch] = useState<number | "">(() => {
|
|
98
110
|
const saved = localStorage.getItem(`${STORAGE_KEY}_MAX_FETCH`);
|
|
@@ -108,6 +120,8 @@ function SearchInputInner<T = { id: string; name: string }>(
|
|
|
108
120
|
|
|
109
121
|
// Fetch items
|
|
110
122
|
useEffect(() => {
|
|
123
|
+
if (!shouldFetch) return; // ⛔ skip until first focus
|
|
124
|
+
|
|
111
125
|
const get = async () => {
|
|
112
126
|
setIsFetching(true);
|
|
113
127
|
try {
|
|
@@ -131,7 +145,7 @@ function SearchInputInner<T = { id: string; name: string }>(
|
|
|
131
145
|
|
|
132
146
|
const combinedParams = new URLSearchParams({
|
|
133
147
|
q: debouncedValue,
|
|
134
|
-
|
|
148
|
+
max: maxFetch?.toString() ?? "",
|
|
135
149
|
...apiConfig.params,
|
|
136
150
|
}).toString();
|
|
137
151
|
|
|
@@ -141,6 +155,7 @@ function SearchInputInner<T = { id: string; name: string }>(
|
|
|
141
155
|
|
|
142
156
|
const res = await window.fetch(url, {
|
|
143
157
|
headers: apiConfig.headers,
|
|
158
|
+
credentials: "include",
|
|
144
159
|
});
|
|
145
160
|
data = await res.json();
|
|
146
161
|
}
|
|
@@ -155,17 +170,45 @@ function SearchInputInner<T = { id: string; name: string }>(
|
|
|
155
170
|
};
|
|
156
171
|
|
|
157
172
|
get();
|
|
158
|
-
}, [debouncedValue, fetch, apiConfig, filtersState, maxFetch]);
|
|
159
|
-
|
|
173
|
+
}, [debouncedValue, fetch, apiConfig, filtersState, maxFetch, shouldFetch]);
|
|
174
|
+
useLayoutEffect(() => {
|
|
175
|
+
if (dropdownRef.current) {
|
|
176
|
+
updatePosition();
|
|
177
|
+
}
|
|
178
|
+
}, [items]);
|
|
160
179
|
const updatePosition = () => {
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
180
|
+
const inputEl = containerRef.current;
|
|
181
|
+
const dropdownEl = dropdownRef.current;
|
|
182
|
+
if (!inputEl || !dropdownEl) return;
|
|
183
|
+
|
|
184
|
+
const rect = inputEl.getBoundingClientRect();
|
|
185
|
+
const viewportHeight = window.innerHeight;
|
|
186
|
+
const gap = 4; // distance between input and dropdown
|
|
187
|
+
|
|
188
|
+
// Actual dropdown height based on content, capped at 260px
|
|
189
|
+
const dropdownHeight = Math.min(dropdownEl.offsetHeight || 0, 260);
|
|
190
|
+
|
|
191
|
+
const spaceBelow = viewportHeight - rect.bottom;
|
|
192
|
+
const spaceAbove = rect.top;
|
|
193
|
+
|
|
194
|
+
// Decide direction
|
|
195
|
+
if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) {
|
|
196
|
+
// Flip above
|
|
197
|
+
setDropDirection("up");
|
|
198
|
+
setPosition({
|
|
199
|
+
top: rect.top + window.scrollY - dropdownHeight - gap,
|
|
200
|
+
left: rect.left + window.scrollX,
|
|
201
|
+
width: rect.width,
|
|
202
|
+
});
|
|
203
|
+
} else {
|
|
204
|
+
// Dropdown below
|
|
205
|
+
setDropDirection("down");
|
|
206
|
+
setPosition({
|
|
207
|
+
top: rect.bottom + window.scrollY + gap,
|
|
208
|
+
left: rect.left + window.scrollX,
|
|
209
|
+
width: rect.width,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
169
212
|
};
|
|
170
213
|
|
|
171
214
|
useEffect(() => {
|
|
@@ -175,6 +218,10 @@ function SearchInputInner<T = { id: string; name: string }>(
|
|
|
175
218
|
setIsFocused(true);
|
|
176
219
|
setShowFilters(false);
|
|
177
220
|
updatePosition();
|
|
221
|
+
// 🟢 First-time fetch trigger
|
|
222
|
+
if (!shouldFetch) {
|
|
223
|
+
setShouldFetch(true);
|
|
224
|
+
}
|
|
178
225
|
};
|
|
179
226
|
el.addEventListener("focus", handleFocus);
|
|
180
227
|
return () => el.removeEventListener("focus", handleFocus);
|
|
@@ -233,6 +280,7 @@ function SearchInputInner<T = { id: string; name: string }>(
|
|
|
233
280
|
const dropdown =
|
|
234
281
|
isFocused || showFilters
|
|
235
282
|
? Dropdown(
|
|
283
|
+
dropDirection,
|
|
236
284
|
position,
|
|
237
285
|
isFetching,
|
|
238
286
|
text,
|
|
@@ -258,9 +306,15 @@ function SearchInputInner<T = { id: string; name: string }>(
|
|
|
258
306
|
{...props}
|
|
259
307
|
value={inputValue}
|
|
260
308
|
onChange={inputOnChange}
|
|
309
|
+
errorMessage={errorMessage}
|
|
310
|
+
startContent={startContent}
|
|
261
311
|
endContent={
|
|
262
312
|
<div className="flex items-center gap-1 relative ltr:-right-1 rtl:-left-1">
|
|
263
|
-
{
|
|
313
|
+
{!showFilters && isFetching ? (
|
|
314
|
+
<LoaderCircle className="size-[38px] p-3 animate-spin" />
|
|
315
|
+
) : (
|
|
316
|
+
isFocused && endIcon
|
|
317
|
+
)}
|
|
264
318
|
{filters.length != 0 && (
|
|
265
319
|
<ListFilter
|
|
266
320
|
onClick={() => {
|
|
@@ -294,6 +348,7 @@ export default SearchInputForward;
|
|
|
294
348
|
|
|
295
349
|
/* Dropdown */
|
|
296
350
|
const Dropdown = <T,>(
|
|
351
|
+
dropDirection: string,
|
|
297
352
|
position: { top: number; left: number; width: number },
|
|
298
353
|
isFetching: boolean,
|
|
299
354
|
text: {
|
|
@@ -315,16 +370,15 @@ const Dropdown = <T,>(
|
|
|
315
370
|
onFiltersChange?: (filtersState: Record<string, boolean>) => void,
|
|
316
371
|
itemOnClick?: (item: T) => void
|
|
317
372
|
) =>
|
|
373
|
+
!isFetching &&
|
|
318
374
|
createPortal(
|
|
319
375
|
<div
|
|
320
376
|
ref={dropdownRef}
|
|
321
|
-
className=
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
position: "absolute",
|
|
327
|
-
}}
|
|
377
|
+
className={cn(
|
|
378
|
+
"absolute z-50 border border-border bg-card shadow-lg pt-3 pb-2",
|
|
379
|
+
dropDirection === "down" ? "rounded-b" : "rounded-t"
|
|
380
|
+
)}
|
|
381
|
+
style={{ top: position.top, left: position.left, width: position.width }}
|
|
328
382
|
>
|
|
329
383
|
{showFilters && filters.length > 0 && (
|
|
330
384
|
<div className="pb-3 px-3 flex flex-col gap-2 text-sm">
|
|
@@ -392,9 +446,6 @@ const Dropdown = <T,>(
|
|
|
392
446
|
)}
|
|
393
447
|
</div>
|
|
394
448
|
)}
|
|
395
|
-
|
|
396
|
-
{!showFilters && isFetching && <CircleLoader label={text.fetch} />}
|
|
397
|
-
|
|
398
449
|
{!showFilters && !isFetching && (
|
|
399
450
|
<div className="max-h-60 overflow-auto">
|
|
400
451
|
{items.length > 0 ? (
|