@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.
- package/dist/index.d.mts +568 -0
- package/dist/index.d.ts +568 -0
- package/dist/index.js +1 -0
- package/dist/index.mjs +1 -0
- package/global.css +53 -0
- package/nativewind-env.d.ts +2 -0
- package/package.json +88 -0
- package/src/MAlert.tsx +38 -0
- package/src/MAnimation.tsx +55 -0
- package/src/MAvatar.tsx +111 -0
- package/src/MBadge.tsx +72 -0
- package/src/MButton.tsx +90 -0
- package/src/MCard.tsx +15 -0
- package/src/MChevron.tsx +47 -0
- package/src/MConfirmation.tsx +68 -0
- package/src/MCountDown.tsx +120 -0
- package/src/MDateTimePicker.tsx +124 -0
- package/src/MDivider.tsx +69 -0
- package/src/MDrawerRightPanel.tsx +187 -0
- package/src/MDropdown.tsx +277 -0
- package/src/MInput.tsx +162 -0
- package/src/MLabel.tsx +3 -0
- package/src/MLucideIcon.tsx +21 -0
- package/src/MModal.tsx +287 -0
- package/src/MNativeAlert.tsx +33 -0
- package/src/MNumpad.tsx +520 -0
- package/src/MPicker.tsx +150 -0
- package/src/MPinPadKeys.tsx +104 -0
- package/src/MPortal.tsx +4 -0
- package/src/MProgressBar.tsx +74 -0
- package/src/MRadioGroup.tsx +4 -0
- package/src/MRequiredLabel.tsx +21 -0
- package/src/MResponsiveContainer.tsx +74 -0
- package/src/MSearch.tsx +138 -0
- package/src/MSelector.tsx +48 -0
- package/src/MSkeleton.tsx +3 -0
- package/src/MSwitch.tsx +13 -0
- package/src/MTable.tsx +17 -0
- package/src/MTabs.tsx +198 -0
- package/src/MText.tsx +51 -0
- package/src/MTimerUp.tsx +88 -0
- package/src/MToggle.tsx +51 -0
- package/src/constants.ts +19 -0
- package/src/hooks/useColorScheme.tsx +12 -0
- package/src/hooks/useIconColors.ts +19 -0
- package/src/index.ts +124 -0
- package/src/primitives/accordion.tsx +143 -0
- package/src/primitives/alert-dialog.tsx +181 -0
- package/src/primitives/alert.tsx +94 -0
- package/src/primitives/aspect-ratio.tsx +5 -0
- package/src/primitives/avatar.tsx +47 -0
- package/src/primitives/badge.tsx +57 -0
- package/src/primitives/button.tsx +92 -0
- package/src/primitives/card.tsx +86 -0
- package/src/primitives/checkbox.tsx +35 -0
- package/src/primitives/collapsible.tsx +9 -0
- package/src/primitives/context-menu.tsx +255 -0
- package/src/primitives/dialog.tsx +166 -0
- package/src/primitives/dropdown-menu.tsx +264 -0
- package/src/primitives/hover-card.tsx +45 -0
- package/src/primitives/input.tsx +25 -0
- package/src/primitives/label.tsx +33 -0
- package/src/primitives/menubar.tsx +266 -0
- package/src/primitives/navigation-menu.tsx +192 -0
- package/src/primitives/popover.tsx +46 -0
- package/src/primitives/progress.tsx +82 -0
- package/src/primitives/radio-group.tsx +42 -0
- package/src/primitives/select.tsx +192 -0
- package/src/primitives/separator.tsx +28 -0
- package/src/primitives/skeleton.tsx +39 -0
- package/src/primitives/switch.tsx +102 -0
- package/src/primitives/table.tsx +107 -0
- package/src/primitives/tabs.tsx +66 -0
- package/src/primitives/text.tsx +28 -0
- package/src/primitives/textarea.tsx +39 -0
- package/src/primitives/toggle-group.tsx +89 -0
- package/src/primitives/toggle.tsx +91 -0
- package/src/primitives/tooltip.tsx +40 -0
- package/src/primitives/typography.tsx +214 -0
- package/src/theme.ts +43 -0
- package/src/tokens.ts +7 -0
- package/src/utils.ts +14 -0
- package/tailwind.config.ts +112 -0
package/src/MNumpad.tsx
ADDED
|
@@ -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;
|
package/src/MPicker.tsx
ADDED
|
@@ -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;
|