notionsoft-ui 1.0.20 → 1.0.22

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.
@@ -0,0 +1,400 @@
1
+ import { defaultCountries } from "./country-data";
2
+ import { LazyFlag } from "./lazy-flag";
3
+ import type { ParsedCountry } from "./type";
4
+ import { cn } from "@/utils/cn";
5
+ import React, {
6
+ useState,
7
+ useRef,
8
+ useEffect,
9
+ useLayoutEffect,
10
+ useMemo,
11
+ } from "react";
12
+ import { createPortal } from "react-dom";
13
+
14
+ interface VirtualListProps {
15
+ items: ParsedCountry[];
16
+ renderRow: (item: ParsedCountry, index: number) => React.ReactNode;
17
+ height: number;
18
+ ROW_HEIGHT: number;
19
+ BUFFER: number;
20
+ }
21
+
22
+ const VirtualList: React.FC<VirtualListProps> = ({
23
+ items,
24
+ renderRow,
25
+ height,
26
+ ROW_HEIGHT,
27
+ BUFFER,
28
+ }) => {
29
+ const [scrollTop, setScrollTop] = useState(0);
30
+
31
+ const totalHeight = items.length * ROW_HEIGHT;
32
+
33
+ // calculate visible indices
34
+ const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - BUFFER);
35
+ const endIndex = Math.min(
36
+ items.length,
37
+ Math.ceil((scrollTop + height) / ROW_HEIGHT) + BUFFER
38
+ );
39
+
40
+ // Slice items to render
41
+ const visibleItems = items.slice(startIndex, endIndex);
42
+
43
+ return (
44
+ <div
45
+ className="overflow-y-auto max-h-60"
46
+ onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
47
+ >
48
+ <div style={{ height: totalHeight, position: "relative" }}>
49
+ {visibleItems.map((item, i) => {
50
+ const index = startIndex + i; // absolute index
51
+ return (
52
+ <div
53
+ key={`${item.iso2}-${index}`} // absolute key ensures React updates
54
+ style={{
55
+ position: "absolute",
56
+ top: index * ROW_HEIGHT,
57
+ left: 0,
58
+ right: 0,
59
+ height: ROW_HEIGHT,
60
+ }}
61
+ >
62
+ {renderRow(item, index)} {/* pass absolute index */}
63
+ </div>
64
+ );
65
+ })}
66
+ </div>
67
+ </div>
68
+ );
69
+ };
70
+ export type PhoneCountryPickerSize = "sm" | "md" | "lg";
71
+
72
+ interface PhoneCountryPickerProps
73
+ extends React.InputHTMLAttributes<HTMLInputElement> {
74
+ requiredHint?: string;
75
+ label?: string;
76
+ errorMessage?: string;
77
+ classNames?: {
78
+ rootDivClassName?: string;
79
+ iconClassName?: string;
80
+ };
81
+ measurement?: PhoneCountryPickerSize;
82
+ ROW_HEIGHT?: number;
83
+ VISIBLE_ROWS?: number;
84
+ BUFFER?: number;
85
+ }
86
+
87
+ export const PhoneCountryPicker: React.FC<PhoneCountryPickerProps> = ({
88
+ measurement = "sm",
89
+ errorMessage,
90
+ label,
91
+ readOnly,
92
+ className,
93
+ classNames,
94
+ requiredHint,
95
+ value,
96
+ onChange,
97
+ ROW_HEIGHT = 32,
98
+ VISIBLE_ROWS = 10,
99
+ BUFFER = 5,
100
+ ...rest
101
+ }) => {
102
+ const { rootDivClassName, iconClassName = "size-4" } = classNames || {};
103
+ const [open, setOpen] = useState(false);
104
+ const [country, setCountry] = useState<ParsedCountry>(defaultCountries[0]);
105
+ const [highlightedIndex, setHighlightedIndex] = useState<number>(0);
106
+ const [phone, setPhone] = useState<string>(
107
+ typeof value == "string" ? value : ""
108
+ );
109
+ const containerRef = useRef<HTMLDivElement>(null);
110
+ const dropdownRef = useRef<HTMLDivElement>(null);
111
+ const inputRef = useRef<HTMLInputElement>(null);
112
+ const [position, setPosition] = useState({ top: 0, left: 0, width: 0 });
113
+
114
+ const [dropDirection, setDropDirection] = useState<"down" | "up">("down");
115
+
116
+ const hasError = !!errorMessage;
117
+
118
+ // Choose country
119
+ const chooseCountry = (c: ParsedCountry) => {
120
+ setCountry(c);
121
+ setOpen(false);
122
+ inputRef.current?.focus();
123
+ };
124
+
125
+ // Keyboard navigation
126
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
127
+ if (!open) {
128
+ if (e.key === "ArrowDown" || e.key === "Enter") {
129
+ setOpen(true);
130
+ e.preventDefault();
131
+ }
132
+ return;
133
+ }
134
+
135
+ if (e.key === "ArrowDown") {
136
+ setHighlightedIndex((prev) =>
137
+ Math.min(prev + 1, defaultCountries.length - 1)
138
+ );
139
+ e.preventDefault();
140
+ } else if (e.key === "ArrowUp") {
141
+ setHighlightedIndex((prev) => Math.max(prev - 1, 0));
142
+ e.preventDefault();
143
+ } else if (e.key === "Enter") {
144
+ chooseCountry(defaultCountries[highlightedIndex]);
145
+ e.preventDefault();
146
+ } else if (e.key === "Escape") {
147
+ setOpen(false);
148
+ e.preventDefault();
149
+ }
150
+ };
151
+
152
+ // Scroll highlighted item into view
153
+ useEffect(() => {
154
+ if (!open || !dropdownRef.current) return;
155
+
156
+ // Get the scrollable container inside the virtual list
157
+ const scrollableContainer =
158
+ dropdownRef.current.querySelector(".overflow-y-auto");
159
+ if (!scrollableContainer) return;
160
+
161
+ const rowTop = highlightedIndex * ROW_HEIGHT;
162
+ const rowBottom = rowTop + ROW_HEIGHT;
163
+ const scrollContainer = scrollableContainer as HTMLDivElement;
164
+
165
+ if (rowTop < scrollContainer.scrollTop) {
166
+ scrollContainer.scrollTop = rowTop;
167
+ } else if (
168
+ rowBottom >
169
+ scrollContainer.scrollTop + ROW_HEIGHT * VISIBLE_ROWS
170
+ ) {
171
+ scrollContainer.scrollTop = rowBottom - ROW_HEIGHT * VISIBLE_ROWS;
172
+ }
173
+ }, [highlightedIndex, open]);
174
+
175
+ // Close on outside click
176
+ useEffect(() => {
177
+ const handleClickOutside = (e: MouseEvent) => {
178
+ if (
179
+ !containerRef.current?.contains(e.target as Node) &&
180
+ !dropdownRef.current?.contains(e.target as Node)
181
+ ) {
182
+ setOpen(false);
183
+ }
184
+ };
185
+ document.addEventListener("mousedown", handleClickOutside);
186
+ return () => document.removeEventListener("mousedown", handleClickOutside);
187
+ }, []);
188
+
189
+ // Update dropdown position
190
+ const updateDropdownPosition = () => {
191
+ const inputEl = containerRef.current;
192
+ const dropdownEl = dropdownRef.current;
193
+ if (!inputEl || !dropdownEl) return;
194
+
195
+ const rect = inputEl.getBoundingClientRect();
196
+ const viewportHeight = window.innerHeight;
197
+ const gap = 4;
198
+
199
+ const dropdownHeight = Math.min(dropdownEl.offsetHeight || 0, 260);
200
+
201
+ const spaceBelow = viewportHeight - rect.bottom;
202
+ const spaceAbove = rect.top;
203
+
204
+ if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) {
205
+ setDropDirection("up");
206
+ setPosition({
207
+ top: rect.top + window.scrollY - dropdownHeight - gap,
208
+ left: rect.left + window.scrollX,
209
+ width: rect.width,
210
+ });
211
+ } else {
212
+ setDropDirection("down");
213
+ setPosition({
214
+ top: rect.bottom + window.scrollY + gap,
215
+ left: rect.left + window.scrollX,
216
+ width: rect.width,
217
+ });
218
+ }
219
+ };
220
+
221
+ useLayoutEffect(() => updateDropdownPosition(), [open]);
222
+ useEffect(() => {
223
+ if (!open) return;
224
+ window.addEventListener("resize", updateDropdownPosition);
225
+ window.addEventListener("scroll", updateDropdownPosition, true);
226
+ return () => {
227
+ window.removeEventListener("resize", updateDropdownPosition);
228
+ window.removeEventListener("scroll", updateDropdownPosition, true);
229
+ };
230
+ }, [open]);
231
+
232
+ // Reset highlighted index when opening
233
+ useEffect(() => {
234
+ if (open) {
235
+ const currentIndex = defaultCountries.findIndex(
236
+ (c) => c.iso2 === country.iso2
237
+ );
238
+ if (currentIndex >= 0) {
239
+ setHighlightedIndex(currentIndex);
240
+ }
241
+ }
242
+ }, [open, country]);
243
+ const heightStyle = useMemo(
244
+ () =>
245
+ measurement == "lg"
246
+ ? {
247
+ height: "50px",
248
+ endContent: label
249
+ ? "ltr:top-[48px] rtl:top-[54px]-translate-y-1/2"
250
+ : "top-[26px] -translate-y-1/2",
251
+ startContent: label
252
+ ? "ltr:top-[48px] rtl:top-[54px] -translate-y-1/2"
253
+ : "top-[26px] -translate-y-1/2",
254
+ required: label ? "ltr:top-[4px] rtl:top-[12px]" : "top-[-19px]",
255
+ }
256
+ : measurement == "md"
257
+ ? {
258
+ height: "44px",
259
+ endContent: label
260
+ ? "ltr:top-[45px] rtl:top-[51px] -translate-y-1/2"
261
+ : "top-[22px] -translate-y-1/2",
262
+ startContent: label
263
+ ? "ltr:top-[45px] rtl:top-[51px] -translate-y-1/2"
264
+ : "top-[22px] -translate-y-1/2",
265
+ required: label ? "ltr:top-[4px] rtl:top-[12px]" : "top-[-19px]",
266
+ }
267
+ : {
268
+ height: "40px",
269
+ endContent: label
270
+ ? "ltr:top-[44px] rtl:top-[50px] -translate-y-1/2"
271
+ : "top-[20px] -translate-y-1/2",
272
+ startContent: label
273
+ ? "ltr:top-[44px] rtl:top-[50px] -translate-y-1/2"
274
+ : "top-[20px] -translate-y-1/2",
275
+ required: label ? "ltr:top-[4px] rtl:top-[12px]" : "top-[-19px]",
276
+ },
277
+ [measurement, label]
278
+ );
279
+ const readOnlyStyle = readOnly && "opacity-40";
280
+
281
+ return (
282
+ <div
283
+ className={cn(
284
+ rootDivClassName,
285
+ "relative flex flex-col w-full",
286
+ readOnlyStyle
287
+ )}
288
+ ref={containerRef}
289
+ onKeyDown={handleKeyDown}
290
+ >
291
+ {/* Required Hint */}
292
+ {requiredHint && (
293
+ <span
294
+ className={cn(
295
+ "absolute font-semibold text-red-600 rtl:text-[13px] ltr:text-[11px] ltr:right-2.5 rtl:left-2.5",
296
+ heightStyle.required
297
+ )}
298
+ >
299
+ {requiredHint}
300
+ </span>
301
+ )}
302
+
303
+ {/* Label */}
304
+ {label && (
305
+ <label
306
+ htmlFor={label}
307
+ className={cn(
308
+ "font-semibold ltr:text-[13px] rtl:text-[18px] text-start inline-block pb-1"
309
+ )}
310
+ >
311
+ {label}
312
+ </label>
313
+ )}
314
+ <div className="flex gap-2">
315
+ <button
316
+ type="button"
317
+ style={{
318
+ height: heightStyle.height,
319
+ }}
320
+ className="flex items-center dark:bg-input/30 gap-2 px-2 border rounded-sm bg-card hover:bg-primary/5 focus:outline-none focus:ring-1 focus:ring-tertiary/60"
321
+ onClick={() => setOpen(!open)}
322
+ aria-haspopup="listbox"
323
+ aria-expanded={open}
324
+ >
325
+ <LazyFlag iso2={country.iso2} className={iconClassName} />
326
+ <span className="text-primary ltr:text-sm rtl:text-sm rtl:font-semibold">
327
+ +{country.dialCode}
328
+ </span>
329
+ </button>
330
+ <input
331
+ ref={inputRef}
332
+ type="tel"
333
+ value={phone}
334
+ onChange={(e) => {
335
+ if (onChange) onChange(e);
336
+ setPhone(e.target.value);
337
+ }}
338
+ placeholder="Phone number"
339
+ style={{
340
+ height: heightStyle.height,
341
+ }}
342
+ className={cn(
343
+ "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 flex w-full min-w-0 rounded-sm border px-3 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70",
344
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
345
+ "appearance-none placeholder:text-primary/60 ltr:text-sm rtl:text-sm rtl:font-semibold focus-visible:ring-0 focus-visible:shadow-sm focus-visible:ring-offset-0 transition-[border] bg-card",
346
+ "focus-visible:border-tertiary/60",
347
+ "[&::-webkit-outer-spin-button]:appearance-none",
348
+ "[&::-webkit-inner-spin-button]:appearance-none",
349
+ "[-moz-appearance:textfield] ",
350
+ hasError && "border-red-400",
351
+ className
352
+ )}
353
+ {...rest}
354
+ disabled={readOnly}
355
+ />
356
+ </div>
357
+
358
+ {open &&
359
+ createPortal(
360
+ <div
361
+ ref={dropdownRef}
362
+ className={cn(
363
+ "absolute z-50 border bg-card shadow-lg",
364
+ dropDirection === "down" ? "rounded-b" : "rounded-t"
365
+ )}
366
+ style={{
367
+ top: position.top,
368
+ left: position.left,
369
+ width: position.width,
370
+ maxHeight: ROW_HEIGHT * VISIBLE_ROWS, // Set maxHeight here instead
371
+ }}
372
+ role="listbox"
373
+ >
374
+ <VirtualList
375
+ ROW_HEIGHT={ROW_HEIGHT}
376
+ BUFFER={BUFFER}
377
+ items={defaultCountries}
378
+ height={ROW_HEIGHT * VISIBLE_ROWS}
379
+ renderRow={(c, i) => (
380
+ <div
381
+ onClick={() => chooseCountry(c)}
382
+ onMouseEnter={() => setHighlightedIndex(i)}
383
+ className={`flex ltr:text-sm rtl:text-sm rtl:font-semibold items-center gap-2 px-2 py-1 cursor-pointer ${
384
+ i == highlightedIndex ? "bg-primary/5" : ""
385
+ }`}
386
+ role="option"
387
+ aria-selected={i === highlightedIndex}
388
+ >
389
+ <LazyFlag iso2={c.iso2} className={iconClassName} />
390
+ <span className="flex-1 truncate">{c.name}</span>
391
+ <span>+{c.dialCode}</span>
392
+ </div>
393
+ )}
394
+ />
395
+ </div>,
396
+ document.body
397
+ )}
398
+ </div>
399
+ );
400
+ };
@@ -0,0 +1,227 @@
1
+ export type CountryIso2 =
2
+ | (string & {}) // allow any string but add autocompletion
3
+ | "af"
4
+ | "al"
5
+ | "dz"
6
+ | "ad"
7
+ | "ao"
8
+ | "ag"
9
+ | "ar"
10
+ | "am"
11
+ | "aw"
12
+ | "au"
13
+ | "at"
14
+ | "az"
15
+ | "bs"
16
+ | "bh"
17
+ | "bd"
18
+ | "bb"
19
+ | "by"
20
+ | "be"
21
+ | "bz"
22
+ | "bj"
23
+ | "bt"
24
+ | "bo"
25
+ | "ba"
26
+ | "bw"
27
+ | "br"
28
+ | "io"
29
+ | "bn"
30
+ | "bg"
31
+ | "bf"
32
+ | "bi"
33
+ | "kh"
34
+ | "cm"
35
+ | "ca"
36
+ | "cv"
37
+ | "bq"
38
+ | "cf"
39
+ | "td"
40
+ | "cl"
41
+ | "cn"
42
+ | "co"
43
+ | "km"
44
+ | "cd"
45
+ | "cg"
46
+ | "cr"
47
+ | "ci"
48
+ | "hr"
49
+ | "cu"
50
+ | "cw"
51
+ | "cy"
52
+ | "cz"
53
+ | "dk"
54
+ | "dj"
55
+ | "dm"
56
+ | "do"
57
+ | "ec"
58
+ | "eg"
59
+ | "sv"
60
+ | "gq"
61
+ | "er"
62
+ | "ee"
63
+ | "et"
64
+ | "fj"
65
+ | "fo"
66
+ | "fi"
67
+ | "fr"
68
+ | "gf"
69
+ | "pf"
70
+ | "ga"
71
+ | "gm"
72
+ | "ge"
73
+ | "de"
74
+ | "gh"
75
+ | "gr"
76
+ | "gd"
77
+ | "gp"
78
+ | "gu"
79
+ | "gt"
80
+ | "gn"
81
+ | "gw"
82
+ | "gy"
83
+ | "ht"
84
+ | "hn"
85
+ | "hk"
86
+ | "hu"
87
+ | "is"
88
+ | "in"
89
+ | "id"
90
+ | "ir"
91
+ | "iq"
92
+ | "ie"
93
+ | "il"
94
+ | "it"
95
+ | "jm"
96
+ | "jp"
97
+ | "jo"
98
+ | "kz"
99
+ | "ke"
100
+ | "ki"
101
+ | "xk"
102
+ | "kw"
103
+ | "kg"
104
+ | "la"
105
+ | "lv"
106
+ | "lb"
107
+ | "ls"
108
+ | "lr"
109
+ | "ly"
110
+ | "li"
111
+ | "lt"
112
+ | "lu"
113
+ | "mo"
114
+ | "mk"
115
+ | "mg"
116
+ | "mw"
117
+ | "my"
118
+ | "mv"
119
+ | "ml"
120
+ | "mt"
121
+ | "mh"
122
+ | "mq"
123
+ | "mr"
124
+ | "mu"
125
+ | "mx"
126
+ | "fm"
127
+ | "md"
128
+ | "mc"
129
+ | "mn"
130
+ | "me"
131
+ | "ma"
132
+ | "mz"
133
+ | "mm"
134
+ | "na"
135
+ | "nr"
136
+ | "np"
137
+ | "nl"
138
+ | "nc"
139
+ | "nz"
140
+ | "ni"
141
+ | "ne"
142
+ | "ng"
143
+ | "kp"
144
+ | "no"
145
+ | "om"
146
+ | "pk"
147
+ | "pw"
148
+ | "ps"
149
+ | "pa"
150
+ | "pg"
151
+ | "py"
152
+ | "pe"
153
+ | "ph"
154
+ | "pl"
155
+ | "pm"
156
+ | "pt"
157
+ | "pr"
158
+ | "qa"
159
+ | "re"
160
+ | "ro"
161
+ | "ru"
162
+ | "rw"
163
+ | "kn"
164
+ | "lc"
165
+ | "vc"
166
+ | "ws"
167
+ | "sm"
168
+ | "st"
169
+ | "sa"
170
+ | "sn"
171
+ | "rs"
172
+ | "sc"
173
+ | "sl"
174
+ | "sg"
175
+ | "sk"
176
+ | "si"
177
+ | "sb"
178
+ | "so"
179
+ | "za"
180
+ | "kr"
181
+ | "ss"
182
+ | "es"
183
+ | "lk"
184
+ | "sd"
185
+ | "sr"
186
+ | "sz"
187
+ | "se"
188
+ | "ch"
189
+ | "sy"
190
+ | "tw"
191
+ | "tj"
192
+ | "tz"
193
+ | "th"
194
+ | "tl"
195
+ | "tg"
196
+ | "to"
197
+ | "tt"
198
+ | "tn"
199
+ | "tr"
200
+ | "tm"
201
+ | "tv"
202
+ | "ug"
203
+ | "ua"
204
+ | "ae"
205
+ | "gb"
206
+ | "us"
207
+ | "uy"
208
+ | "uz"
209
+ | "vu"
210
+ | "va"
211
+ | "ve"
212
+ | "vn"
213
+ | "wf"
214
+ | "ye"
215
+ | "yt"
216
+ | "zm"
217
+ | "zw";
218
+ type FormatConfig = Record<string, string> & { default: string };
219
+
220
+ export interface ParsedCountry {
221
+ name: string;
222
+ iso2: CountryIso2;
223
+ dialCode: string;
224
+ format?: string | FormatConfig; // make sure this line exists
225
+ priority?: number;
226
+ areaCodes?: string[];
227
+ }
@@ -0,0 +1,23 @@
1
+ const alphabet = "abcdefghijklmnopqrstuvwxyz";
2
+ const A_LETTER_CODEPOINT = "1f1e6";
3
+
4
+ const incrementCodepoint = (codePoint: string, incrementBy: number): string => {
5
+ const decimal = parseInt(codePoint, 16);
6
+ return (decimal + incrementBy).toString(16);
7
+ };
8
+
9
+ const codepoints: Record<string, string> = alphabet.split("").reduce(
10
+ (obj, currentLetter, index) => ({
11
+ ...obj,
12
+ [currentLetter]: incrementCodepoint(A_LETTER_CODEPOINT, index),
13
+ }),
14
+ {}
15
+ );
16
+ export const getFlagCodepoint = (iso2: string) => {
17
+ if (!iso2 || iso2.length !== 2) return "";
18
+ const lower = iso2.toLowerCase();
19
+ const first = codepoints[lower[0]];
20
+ const second = codepoints[lower[1]];
21
+ if (!first || !second) return "";
22
+ return `${first}-${second}`;
23
+ };