@munchi_oy/native-ui 0.1.0

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.
Files changed (83) hide show
  1. package/dist/index.d.mts +568 -0
  2. package/dist/index.d.ts +568 -0
  3. package/dist/index.js +1 -0
  4. package/dist/index.mjs +1 -0
  5. package/global.css +53 -0
  6. package/nativewind-env.d.ts +2 -0
  7. package/package.json +88 -0
  8. package/src/MAlert.tsx +38 -0
  9. package/src/MAnimation.tsx +55 -0
  10. package/src/MAvatar.tsx +111 -0
  11. package/src/MBadge.tsx +72 -0
  12. package/src/MButton.tsx +90 -0
  13. package/src/MCard.tsx +15 -0
  14. package/src/MChevron.tsx +47 -0
  15. package/src/MConfirmation.tsx +68 -0
  16. package/src/MCountDown.tsx +120 -0
  17. package/src/MDateTimePicker.tsx +124 -0
  18. package/src/MDivider.tsx +69 -0
  19. package/src/MDrawerRightPanel.tsx +187 -0
  20. package/src/MDropdown.tsx +277 -0
  21. package/src/MInput.tsx +162 -0
  22. package/src/MLabel.tsx +3 -0
  23. package/src/MLucideIcon.tsx +21 -0
  24. package/src/MModal.tsx +287 -0
  25. package/src/MNativeAlert.tsx +33 -0
  26. package/src/MNumpad.tsx +520 -0
  27. package/src/MPicker.tsx +150 -0
  28. package/src/MPinPadKeys.tsx +104 -0
  29. package/src/MPortal.tsx +4 -0
  30. package/src/MProgressBar.tsx +74 -0
  31. package/src/MRadioGroup.tsx +4 -0
  32. package/src/MRequiredLabel.tsx +21 -0
  33. package/src/MResponsiveContainer.tsx +74 -0
  34. package/src/MSearch.tsx +138 -0
  35. package/src/MSelector.tsx +48 -0
  36. package/src/MSkeleton.tsx +3 -0
  37. package/src/MSwitch.tsx +13 -0
  38. package/src/MTable.tsx +17 -0
  39. package/src/MTabs.tsx +198 -0
  40. package/src/MText.tsx +51 -0
  41. package/src/MTimerUp.tsx +88 -0
  42. package/src/MToggle.tsx +51 -0
  43. package/src/constants.ts +19 -0
  44. package/src/hooks/useColorScheme.tsx +12 -0
  45. package/src/hooks/useIconColors.ts +19 -0
  46. package/src/index.ts +124 -0
  47. package/src/primitives/accordion.tsx +143 -0
  48. package/src/primitives/alert-dialog.tsx +181 -0
  49. package/src/primitives/alert.tsx +94 -0
  50. package/src/primitives/aspect-ratio.tsx +5 -0
  51. package/src/primitives/avatar.tsx +47 -0
  52. package/src/primitives/badge.tsx +57 -0
  53. package/src/primitives/button.tsx +92 -0
  54. package/src/primitives/card.tsx +86 -0
  55. package/src/primitives/checkbox.tsx +35 -0
  56. package/src/primitives/collapsible.tsx +9 -0
  57. package/src/primitives/context-menu.tsx +255 -0
  58. package/src/primitives/dialog.tsx +166 -0
  59. package/src/primitives/dropdown-menu.tsx +264 -0
  60. package/src/primitives/hover-card.tsx +45 -0
  61. package/src/primitives/input.tsx +25 -0
  62. package/src/primitives/label.tsx +33 -0
  63. package/src/primitives/menubar.tsx +266 -0
  64. package/src/primitives/navigation-menu.tsx +192 -0
  65. package/src/primitives/popover.tsx +46 -0
  66. package/src/primitives/progress.tsx +82 -0
  67. package/src/primitives/radio-group.tsx +42 -0
  68. package/src/primitives/select.tsx +192 -0
  69. package/src/primitives/separator.tsx +28 -0
  70. package/src/primitives/skeleton.tsx +39 -0
  71. package/src/primitives/switch.tsx +102 -0
  72. package/src/primitives/table.tsx +107 -0
  73. package/src/primitives/tabs.tsx +66 -0
  74. package/src/primitives/text.tsx +28 -0
  75. package/src/primitives/textarea.tsx +39 -0
  76. package/src/primitives/toggle-group.tsx +89 -0
  77. package/src/primitives/toggle.tsx +91 -0
  78. package/src/primitives/tooltip.tsx +40 -0
  79. package/src/primitives/typography.tsx +214 -0
  80. package/src/theme.ts +43 -0
  81. package/src/tokens.ts +7 -0
  82. package/src/utils.ts +14 -0
  83. package/tailwind.config.ts +112 -0
@@ -0,0 +1,520 @@
1
+ import { cva } from "class-variance-authority";
2
+ import { Delete } from "lucide-react-native";
3
+ import type React from "react";
4
+ import { useCallback, useMemo, useRef, useState } from "react";
5
+ import {
6
+ Platform,
7
+ Pressable,
8
+ Text,
9
+ View,
10
+ useWindowDimensions
11
+ } from "react-native";
12
+ import { useColorScheme } from "./hooks/useColorScheme";
13
+ import { useIconColors } from "./hooks/useIconColors";
14
+ import { cn } from "./utils";
15
+
16
+ export enum NumpadMode {
17
+ Discount = "discount",
18
+ Table = "table",
19
+ Price = "price",
20
+ Cash = "cash",
21
+ Quantity = "quantity",
22
+ Phone = "phone",
23
+ GiftCard = "giftcard"
24
+ }
25
+
26
+ export const getDecimalSeparator = () => {
27
+ const formatted = new Intl.NumberFormat(undefined, {
28
+ minimumFractionDigits: 1,
29
+ maximumFractionDigits: 1
30
+ }).format(1.1);
31
+ return formatted.match(/[^0-9]/)?.[0] || ".";
32
+ };
33
+
34
+ function toInternalDot(raw: string, decSep: string): string {
35
+ if (!raw) return "";
36
+ if (decSep === ".") {
37
+ const [head = "", ...tails] = raw.split(".");
38
+ if (tails.length === 0) return head;
39
+ return `${head}.${tails.join("")}`;
40
+ }
41
+ if (raw.includes(".")) return raw;
42
+ const parts = raw.split(decSep);
43
+ if (parts.length <= 1) return raw.replace(/\./g, "");
44
+ return `${(parts[0] ?? "").replace(/\./g, "")}.${parts.slice(1).join("")}`;
45
+ }
46
+
47
+ /**
48
+ * Cash-register style helpers: the numpad stores an integer cents string
49
+ * internally (e.g. "1258" means 12.58). All callers still receive/emit a
50
+ * decimal display string like "12.58" / "12,58".
51
+ */
52
+ function centsToDisplay(cents: string, decSep: string): string {
53
+ const n = cents.replace(/\D/g, "");
54
+ if (n === "" || n === "0") return `0${decSep}00`;
55
+ const padded = n.padStart(3, "0");
56
+ const whole = padded.slice(0, -2).replace(/^0+/, "") || "0";
57
+ const frac = padded.slice(-2);
58
+ return `${whole}${decSep}${frac}`;
59
+ }
60
+
61
+ function displayToCents(display: string, decSep: string): string {
62
+ if (!display) return "";
63
+ const withDot = decSep === "." ? display : display.replace(decSep, ".");
64
+ const parsed = Number.parseFloat(withDot);
65
+ if (Number.isNaN(parsed) || parsed <= 0) return "";
66
+ return Math.round(parsed * 100).toString();
67
+ }
68
+
69
+ export interface MNumpadProps {
70
+ customAmount: string | null;
71
+ setCustomAmount: (amount: string | null) => void;
72
+ mode: NumpadMode;
73
+ maxDigits?: number;
74
+ }
75
+
76
+ const CELL_SIZE_PHONE = 90;
77
+ const CELL_SIZE_TABLET = 118;
78
+ const CELL_SIZE_PHONE_ANDROID = 78;
79
+ const CELL_SIZE_TABLET_ANDROID = 106;
80
+ const CELL_HEIGHT_PHONE = 72;
81
+ const CELL_HEIGHT_TABLET = 90;
82
+ const CELL_HEIGHT_PHONE_ANDROID = 62;
83
+ const CELL_HEIGHT_TABLET_ANDROID = 80;
84
+ const FONT_SIZE_PHONE = 24;
85
+ const FONT_SIZE_TABLET = 36;
86
+ const FONT_SIZE_PHONE_ANDROID = 22;
87
+ const FONT_SIZE_TABLET_ANDROID = 32;
88
+ const GAP = 6;
89
+ const GAP_ANDROID = 5;
90
+ const COLS = 3;
91
+ const DOUBLE_ZERO = "00";
92
+
93
+ const PLACEHOLDER_CENTS = /^(\d+)\.00$/;
94
+
95
+ function numpadKeyTestId(item: string): string {
96
+ switch (item) {
97
+ case "X":
98
+ return "munchi.numpad.key.backspace";
99
+ case DOUBLE_ZERO:
100
+ return "munchi.numpad.key.double-zero";
101
+ case "-":
102
+ return "munchi.numpad.key.minus";
103
+ case "+":
104
+ return "munchi.numpad.key.plus";
105
+ default:
106
+ return `munchi.numpad.key.digit-${item}`;
107
+ }
108
+ }
109
+
110
+ const numpadKeyVariants = cva("items-center justify-center rounded-2xl", {
111
+ variants: {
112
+ pressed: { true: "", false: "bg-muted" },
113
+ scheme: { dark: "", light: "" }
114
+ },
115
+ compoundVariants: [
116
+ {
117
+ pressed: true,
118
+ scheme: "dark",
119
+ className: "bg-white/15 border-white/20"
120
+ },
121
+ {
122
+ pressed: true,
123
+ scheme: "light",
124
+ className: "bg-foreground/10 border-foreground/15"
125
+ }
126
+ ],
127
+ defaultVariants: { pressed: false, scheme: "light" }
128
+ });
129
+
130
+ export const MNumpad: React.FC<MNumpadProps> = ({
131
+ customAmount,
132
+ setCustomAmount,
133
+ mode,
134
+ maxDigits = 6
135
+ }) => {
136
+ const decimalSeparator = getDecimalSeparator();
137
+ const isCashRegisterMode =
138
+ mode === NumpadMode.Price ||
139
+ mode === NumpadMode.Cash ||
140
+ mode === NumpadMode.Discount;
141
+ const isPriceMode = isCashRegisterMode;
142
+ const isPhoneMode = mode === NumpadMode.Phone;
143
+ const amountRef = useRef<string | null>(customAmount);
144
+ amountRef.current = customAmount;
145
+ const postDoubleZeroRef = useRef(0);
146
+ const { foreground } = useIconColors();
147
+ const { colorScheme } = useColorScheme();
148
+ const { width } = useWindowDimensions();
149
+
150
+ const isTablet = width >= 768;
151
+ const isAndroid = Platform.OS === "android";
152
+
153
+ const cellSize = isAndroid
154
+ ? isTablet
155
+ ? CELL_SIZE_TABLET_ANDROID
156
+ : CELL_SIZE_PHONE_ANDROID
157
+ : isTablet
158
+ ? CELL_SIZE_TABLET
159
+ : CELL_SIZE_PHONE;
160
+ const cellHeight = isAndroid
161
+ ? isTablet
162
+ ? CELL_HEIGHT_TABLET_ANDROID
163
+ : CELL_HEIGHT_PHONE_ANDROID
164
+ : isTablet
165
+ ? CELL_HEIGHT_TABLET
166
+ : CELL_HEIGHT_PHONE;
167
+ const gap = isAndroid ? GAP_ANDROID : GAP;
168
+ const fontSize = isAndroid
169
+ ? isTablet
170
+ ? FONT_SIZE_TABLET_ANDROID
171
+ : FONT_SIZE_PHONE_ANDROID
172
+ : isTablet
173
+ ? FONT_SIZE_TABLET
174
+ : FONT_SIZE_PHONE;
175
+
176
+ const scheme = colorScheme === "dark" ? "dark" : "light";
177
+
178
+ const handleNumPress = useCallback(
179
+ (input: string) => {
180
+ if (isCashRegisterMode) {
181
+ const isDoubleZero = input === DOUBLE_ZERO;
182
+ if (!isDoubleZero && !/^\d$/.test(input)) return;
183
+
184
+ const cents = displayToCents(amountRef.current ?? "", decimalSeparator);
185
+
186
+ if (isDoubleZero) {
187
+ const nextCents = `${cents}00`.replace(/^0+/, "") || "";
188
+ if (nextCents === "") {
189
+ postDoubleZeroRef.current = 0;
190
+ return;
191
+ }
192
+ if (nextCents.length > maxDigits + 2) return;
193
+ postDoubleZeroRef.current = 2;
194
+ const display = centsToDisplay(nextCents, decimalSeparator);
195
+ amountRef.current = display;
196
+ setCustomAmount(display);
197
+ return;
198
+ }
199
+
200
+ // Single digit press
201
+ if (postDoubleZeroRef.current > 0) {
202
+ // In cents-fill mode: digits shift left within the two cent slots only
203
+ // 1.00 + 5 → 1.05, then 1.05 + 6 → 1.56, then resume normal penny-push
204
+ const centsStr = cents.padStart(3, "0");
205
+ const wholeDigits = centsStr.slice(0, -2);
206
+ const centsDigits = centsStr.slice(-2);
207
+ const newCentsDigits = (centsDigits[1] ?? "0") + input;
208
+ const nextCents =
209
+ (wholeDigits + newCentsDigits).replace(/^0+/, "") || "";
210
+ if (nextCents.length > maxDigits + 2) return;
211
+ postDoubleZeroRef.current -= 1;
212
+ const display = centsToDisplay(nextCents, decimalSeparator);
213
+ amountRef.current = display;
214
+ setCustomAmount(display);
215
+ return;
216
+ }
217
+
218
+ // Normal penny-push: append digit to the right
219
+ const next = (cents + input).replace(/^0+/, "") || "";
220
+ if (next.length > maxDigits + 2) return;
221
+ if (next === "") {
222
+ amountRef.current = null;
223
+ setCustomAmount(null);
224
+ return;
225
+ }
226
+ const display = centsToDisplay(next, decimalSeparator);
227
+ amountRef.current = display;
228
+ setCustomAmount(display);
229
+ return;
230
+ }
231
+
232
+ const raw = amountRef.current ?? "";
233
+ const internal = toInternalDot(raw, decimalSeparator);
234
+ const [whole = "", fraction = ""] = internal.split(".");
235
+ const hasDot = internal.includes(".");
236
+ const isSep = input === decimalSeparator;
237
+ const isDoubleZeroKey = input === DOUBLE_ZERO && isPriceMode;
238
+
239
+ if (isDoubleZeroKey) {
240
+ if (!hasDot) {
241
+ const next = whole === "" ? "0.00" : `${whole}.00`;
242
+ amountRef.current = next;
243
+ setCustomAmount(next);
244
+ return;
245
+ }
246
+ const stripMatch = PLACEHOLDER_CENTS.exec(internal);
247
+ if (stripMatch) {
248
+ const w = stripMatch[1] ?? "";
249
+ amountRef.current = w;
250
+ setCustomAmount(w);
251
+ return;
252
+ }
253
+ return;
254
+ }
255
+
256
+ if (isSep) {
257
+ if (hasDot) return;
258
+ setCustomAmount(raw + decimalSeparator);
259
+ return;
260
+ }
261
+
262
+ const placeholderMatch = isPriceMode
263
+ ? PLACEHOLDER_CENTS.exec(internal)
264
+ : null;
265
+ if (placeholderMatch && /^\d$/.test(input)) {
266
+ const w = placeholderMatch[1] ?? "";
267
+ if (input === "0") {
268
+ const nextWhole = `${w}0`;
269
+ if (nextWhole.length > maxDigits) {
270
+ return;
271
+ }
272
+ const next = `${nextWhole}.00`;
273
+ amountRef.current = next;
274
+ setCustomAmount(next);
275
+ return;
276
+ }
277
+ const next = `${w}.${input}0`;
278
+ amountRef.current = next;
279
+ setCustomAmount(next);
280
+ return;
281
+ }
282
+
283
+ if (isPriceMode && hasDot) {
284
+ if (
285
+ fraction.length === 2 &&
286
+ fraction[0] !== "0" &&
287
+ fraction[1] !== "0" &&
288
+ /^\d$/.test(input)
289
+ ) {
290
+ const newWhole = `${whole}${input}`;
291
+ if (newWhole.length > maxDigits) {
292
+ return;
293
+ }
294
+ const next = `${newWhole}.${fraction}`;
295
+ amountRef.current = next;
296
+ setCustomAmount(next);
297
+ return;
298
+ }
299
+ if (fraction.length === 2 && fraction[1] === "0") {
300
+ const next = `${whole}.${fraction[0]}${input}`;
301
+ amountRef.current = next;
302
+ setCustomAmount(next);
303
+ return;
304
+ }
305
+ if (fraction.length === 1 && /^\d$/.test(fraction)) {
306
+ const next = `${whole}.${fraction}${input}`;
307
+ amountRef.current = next;
308
+ setCustomAmount(next);
309
+ return;
310
+ }
311
+ if (fraction.length >= 2) return;
312
+ }
313
+
314
+ if (isPriceMode) {
315
+ if (!hasDot && whole.length >= maxDigits) return;
316
+ } else if (whole.length >= maxDigits) return;
317
+
318
+ const nextRaw = raw + input;
319
+ const nextInternal = toInternalDot(nextRaw, decimalSeparator);
320
+ amountRef.current = nextInternal;
321
+ setCustomAmount(nextInternal);
322
+ },
323
+ [
324
+ decimalSeparator,
325
+ isCashRegisterMode,
326
+ isPriceMode,
327
+ maxDigits,
328
+ setCustomAmount
329
+ ]
330
+ );
331
+
332
+ const handleBackspace = useCallback(() => {
333
+ const raw = amountRef.current;
334
+
335
+ if (isCashRegisterMode) {
336
+ postDoubleZeroRef.current = 0;
337
+ if (raw == null || raw === "") {
338
+ amountRef.current = null;
339
+ setCustomAmount(null);
340
+ return;
341
+ }
342
+ const cents = displayToCents(raw, decimalSeparator);
343
+ const trimmed = cents.slice(0, -1).replace(/^0+/, "") || "";
344
+ if (trimmed === "") {
345
+ amountRef.current = null;
346
+ setCustomAmount(null);
347
+ return;
348
+ }
349
+ const display = centsToDisplay(trimmed, decimalSeparator);
350
+ amountRef.current = display;
351
+ setCustomAmount(display);
352
+ return;
353
+ }
354
+
355
+ if (raw == null || raw === "") {
356
+ amountRef.current = null;
357
+ setCustomAmount(null);
358
+ return;
359
+ }
360
+ const internal = toInternalDot(raw, decimalSeparator);
361
+ if (!internal.includes(".")) {
362
+ const nextInternal = internal.slice(0, -1);
363
+ if (nextInternal === "") {
364
+ amountRef.current = null;
365
+ setCustomAmount(null);
366
+ return;
367
+ }
368
+ amountRef.current = nextInternal;
369
+ setCustomAmount(nextInternal);
370
+ return;
371
+ }
372
+ const [w = "", frac = ""] = internal.split(".");
373
+ if (frac.length > 0) {
374
+ const nextFrac = frac.slice(0, -1);
375
+ if (nextFrac === "") {
376
+ amountRef.current = w === "" ? null : w;
377
+ setCustomAmount(w === "" ? null : w);
378
+ return;
379
+ }
380
+ const next = `${w}.${nextFrac}`;
381
+ amountRef.current = next;
382
+ setCustomAmount(next);
383
+ return;
384
+ }
385
+ amountRef.current = w === "" ? null : w;
386
+ setCustomAmount(w === "" ? null : w);
387
+ }, [decimalSeparator, isCashRegisterMode, setCustomAmount]);
388
+
389
+ const keypadLayout = useMemo(
390
+ () => [
391
+ "1",
392
+ "2",
393
+ "3",
394
+ "4",
395
+ "5",
396
+ "6",
397
+ "7",
398
+ "8",
399
+ "9",
400
+ mode === NumpadMode.GiftCard
401
+ ? "-"
402
+ : isPriceMode
403
+ ? DOUBLE_ZERO
404
+ : isPhoneMode
405
+ ? "+"
406
+ : "",
407
+ "0",
408
+ "X"
409
+ ],
410
+ [mode, isPriceMode, isPhoneMode]
411
+ );
412
+
413
+ const [containerWidth, setContainerWidth] = useState(0);
414
+
415
+ const naturalWidth = cellSize * COLS + gap * (COLS - 1);
416
+
417
+ const effectiveCellSize =
418
+ containerWidth > 0 && containerWidth < naturalWidth
419
+ ? Math.floor((containerWidth - gap * (COLS - 1)) / COLS)
420
+ : cellSize;
421
+
422
+ const derivedCellHeight = Math.round(
423
+ effectiveCellSize * (cellHeight / cellSize)
424
+ );
425
+ const derivedFontSize = Math.round(fontSize * (effectiveCellSize / cellSize));
426
+
427
+ return (
428
+ <View
429
+ style={{
430
+ width: naturalWidth,
431
+ maxWidth: "100%",
432
+ alignSelf: "center",
433
+ gap
434
+ }}
435
+ onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
436
+ >
437
+ {[0, 1, 2, 3].map((row) => (
438
+ <View key={row} style={{ flexDirection: "row", gap }}>
439
+ {keypadLayout
440
+ .slice(row * COLS, row * COLS + COLS)
441
+ .map((item, col) => {
442
+ const key = `${row}-${col}`;
443
+ if (item === "") {
444
+ return (
445
+ <View
446
+ key={key}
447
+ style={{
448
+ width: effectiveCellSize,
449
+ height: derivedCellHeight
450
+ }}
451
+ />
452
+ );
453
+ }
454
+ return (
455
+ <NumpadKey
456
+ key={key}
457
+ testID={numpadKeyTestId(item)}
458
+ item={item}
459
+ width={effectiveCellSize}
460
+ height={derivedCellHeight}
461
+ fontSize={derivedFontSize}
462
+ foreground={foreground}
463
+ scheme={scheme}
464
+ onPress={
465
+ item === "X" ? handleBackspace : () => handleNumPress(item)
466
+ }
467
+ />
468
+ );
469
+ })}
470
+ </View>
471
+ ))}
472
+ </View>
473
+ );
474
+ };
475
+
476
+ type NumpadKeyProps = {
477
+ testID: string;
478
+ item: string;
479
+ width: number;
480
+ height: number;
481
+ fontSize: number;
482
+ foreground: string;
483
+ scheme: "dark" | "light";
484
+ onPress: () => void;
485
+ };
486
+
487
+ const NumpadKey: React.FC<NumpadKeyProps> = ({
488
+ testID,
489
+ item,
490
+ width,
491
+ height,
492
+ fontSize,
493
+ foreground,
494
+ scheme,
495
+ onPress
496
+ }) => (
497
+ <Pressable testID={testID} onPress={onPress} style={{ width, height }}>
498
+ {({ pressed }) => (
499
+ <View
500
+ style={{ width, height }}
501
+ className={cn(numpadKeyVariants({ pressed, scheme }))}
502
+ >
503
+ {item === "X" ? (
504
+ <Delete size={fontSize} color={foreground} />
505
+ ) : (
506
+ <Text
507
+ style={{ fontSize, lineHeight: fontSize * 1.4 }}
508
+ className="text-foreground"
509
+ >
510
+ {item}
511
+ </Text>
512
+ )}
513
+ </View>
514
+ )}
515
+ </Pressable>
516
+ );
517
+
518
+ MNumpad.displayName = "MNumpad";
519
+
520
+ export default MNumpad;
@@ -0,0 +1,150 @@
1
+ import type React from "react";
2
+ import { useRef, useState } from "react";
3
+ import {
4
+ FlatList,
5
+ Modal,
6
+ Pressable,
7
+ type PressableProps,
8
+ Text,
9
+ type TextProps,
10
+ View,
11
+ type ViewProps
12
+ } from "react-native";
13
+ import { cn } from "./utils";
14
+
15
+ export type PickerItem<T> = {
16
+ label: string;
17
+ value: T;
18
+ };
19
+
20
+ export type MPickerProps<T> = Pick<PressableProps, "className"> & {
21
+ data: PickerItem<T>[];
22
+ onSelect: (item: PickerItem<T>) => void;
23
+ placeholder: string;
24
+ selectedValue?: T | null;
25
+ textClassName?: TextProps["className"];
26
+ dropdownContainerClassName?: ViewProps["className"];
27
+ itemContainerClassName?: PressableProps["className"];
28
+ itemTextClassName?: TextProps["className"];
29
+ };
30
+
31
+ export const MPicker = <T,>({
32
+ data,
33
+ onSelect,
34
+ placeholder,
35
+ selectedValue,
36
+ className,
37
+ textClassName,
38
+ dropdownContainerClassName,
39
+ itemContainerClassName,
40
+ itemTextClassName
41
+ }: MPickerProps<T>): React.ReactElement => {
42
+ const [isVisible, setIsVisible] = useState<boolean>(false);
43
+ const buttonRef = useRef<View>(null);
44
+ const [buttonLayout, setButtonLayout] = useState({
45
+ x: 0,
46
+ y: 0,
47
+ width: 0,
48
+ height: 0
49
+ });
50
+
51
+ const selectedItem = data.find((item) => item.value === selectedValue);
52
+
53
+ const handleSelect = (item: PickerItem<T>) => {
54
+ onSelect(item);
55
+ setIsVisible(false);
56
+ };
57
+
58
+ const renderItem = ({ item }: { item: PickerItem<T> }) => {
59
+ const isSelected = item.value === selectedValue;
60
+
61
+ return (
62
+ <Pressable
63
+ className={cn(
64
+ "p-3 rounded-md m-1",
65
+ isSelected ? "bg-primary" : "active:bg-muted",
66
+ itemContainerClassName
67
+ )}
68
+ onPress={() => handleSelect(item)}
69
+ >
70
+ <Text
71
+ className={cn(
72
+ isSelected ? "text-primary-foreground" : "text-foreground",
73
+ itemTextClassName
74
+ )}
75
+ numberOfLines={1}
76
+ >
77
+ {item.label}
78
+ </Text>
79
+ </Pressable>
80
+ );
81
+ };
82
+
83
+ const handlePress = () => {
84
+ if (!isVisible) {
85
+ buttonRef.current?.measureInWindow((x, y, width, height) => {
86
+ setButtonLayout({ x, y, width, height });
87
+ setIsVisible(true);
88
+ });
89
+ } else {
90
+ setIsVisible(false);
91
+ }
92
+ };
93
+
94
+ return (
95
+ <View className="flex-col items-center justify-center">
96
+ <Pressable
97
+ ref={buttonRef}
98
+ className={cn(
99
+ "bg-primary rounded-full py-4 px-6 flex-row items-center",
100
+ className
101
+ )}
102
+ onPress={handlePress}
103
+ >
104
+ <Text
105
+ className={cn("text-primary-foreground text-base", textClassName)}
106
+ numberOfLines={1}
107
+ ellipsizeMode="tail"
108
+ >
109
+ {selectedItem ? selectedItem.label : placeholder}
110
+ </Text>
111
+ </Pressable>
112
+
113
+ <Modal
114
+ animationType="none"
115
+ transparent={true}
116
+ visible={isVisible}
117
+ onRequestClose={() => {
118
+ setIsVisible(false);
119
+ }}
120
+ >
121
+ <Pressable
122
+ className="absolute inset-0"
123
+ onPress={() => setIsVisible(false)}
124
+ />
125
+ <View
126
+ style={{
127
+ position: "absolute",
128
+ top: buttonLayout.y + buttonLayout.height,
129
+ left: buttonLayout.x
130
+ }}
131
+ className={cn(
132
+ "min-w-[100px] bg-background shadow-lg rounded-lg border border-border p-1 z-50 mt-2",
133
+ dropdownContainerClassName
134
+ )}
135
+ >
136
+ <FlatList
137
+ data={data}
138
+ renderItem={renderItem}
139
+ keyExtractor={(item) => String(item.value)}
140
+ className="max-h-[200px]"
141
+ />
142
+ </View>
143
+ </Modal>
144
+ </View>
145
+ );
146
+ };
147
+
148
+ MPicker.displayName = "MPicker";
149
+
150
+ export default MPicker;