notionsoft-ui 1.0.18 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notionsoft-ui",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
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"
@@ -120,7 +120,6 @@ function MultiSelectInputInner<T = any>(
120
120
  const [pendingSelection, setPendingSelection] = useState<T[] | T | null>(
121
121
  null
122
122
  );
123
-
124
123
  const inputRef = useRef<HTMLInputElement>(null);
125
124
  const containerRef = useRef<HTMLDivElement>(null);
126
125
  const wrapperRef = useRef<HTMLDivElement>(null);
@@ -0,0 +1,3 @@
1
+ import PasswordInput from "./password-input";
2
+
3
+ export default PasswordInput;
@@ -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, { useCallback, useEffect, useRef, useState } from "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
- maxFetch: maxFetch?.toString() ?? "",
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 el = containerRef.current;
162
- if (!el) return;
163
- const rect = el.getBoundingClientRect();
164
- setPosition({
165
- top: rect.bottom + window.scrollY,
166
- left: rect.left + window.scrollX,
167
- width: rect.width,
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
- {isFocused && endIcon}
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="absolute z-9999 border border-border rounded-b bg-card shadow-lg pt-3 pb-2"
322
- style={{
323
- top: position.top,
324
- left: position.left,
325
- width: position.width,
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 ? (