@rovula/ui 0.0.76 → 0.0.77

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 (41) hide show
  1. package/dist/cjs/bundle.css +12 -0
  2. package/dist/cjs/bundle.js +3 -3
  3. package/dist/cjs/bundle.js.map +1 -1
  4. package/dist/cjs/types/components/Dropdown/Dropdown.stories.d.ts +7 -0
  5. package/dist/cjs/types/components/InputFilter/InputFilter.stories.d.ts +7 -0
  6. package/dist/cjs/types/components/NumberInput/NumberInput.d.ts +39 -0
  7. package/dist/cjs/types/components/NumberInput/NumberInput.stories.d.ts +18 -0
  8. package/dist/cjs/types/components/NumberInput/index.d.ts +2 -0
  9. package/dist/cjs/types/components/RadioGroup/RadioGroup.stories.d.ts +1 -1
  10. package/dist/cjs/types/components/Search/Search.stories.d.ts +7 -0
  11. package/dist/cjs/types/components/Slider/Slider.stories.d.ts +1 -1
  12. package/dist/cjs/types/components/TextInput/TextInput.d.ts +14 -0
  13. package/dist/cjs/types/components/TextInput/TextInput.stories.d.ts +14 -0
  14. package/dist/cjs/types/index.d.ts +2 -0
  15. package/dist/components/NumberInput/NumberInput.js +254 -0
  16. package/dist/components/NumberInput/NumberInput.stories.js +212 -0
  17. package/dist/components/NumberInput/index.js +1 -0
  18. package/dist/components/TextInput/TextInput.js +13 -11
  19. package/dist/esm/bundle.css +12 -0
  20. package/dist/esm/bundle.js +3 -3
  21. package/dist/esm/bundle.js.map +1 -1
  22. package/dist/esm/types/components/Dropdown/Dropdown.stories.d.ts +7 -0
  23. package/dist/esm/types/components/InputFilter/InputFilter.stories.d.ts +7 -0
  24. package/dist/esm/types/components/NumberInput/NumberInput.d.ts +39 -0
  25. package/dist/esm/types/components/NumberInput/NumberInput.stories.d.ts +18 -0
  26. package/dist/esm/types/components/NumberInput/index.d.ts +2 -0
  27. package/dist/esm/types/components/RadioGroup/RadioGroup.stories.d.ts +1 -1
  28. package/dist/esm/types/components/Search/Search.stories.d.ts +7 -0
  29. package/dist/esm/types/components/Slider/Slider.stories.d.ts +1 -1
  30. package/dist/esm/types/components/TextInput/TextInput.d.ts +14 -0
  31. package/dist/esm/types/components/TextInput/TextInput.stories.d.ts +14 -0
  32. package/dist/esm/types/index.d.ts +2 -0
  33. package/dist/index.d.ts +52 -1
  34. package/dist/index.js +1 -0
  35. package/dist/src/theme/global.css +16 -0
  36. package/package.json +1 -1
  37. package/src/components/NumberInput/NumberInput.stories.tsx +350 -0
  38. package/src/components/NumberInput/NumberInput.tsx +428 -0
  39. package/src/components/NumberInput/index.ts +2 -0
  40. package/src/components/TextInput/TextInput.tsx +54 -12
  41. package/src/index.ts +2 -0
@@ -0,0 +1,428 @@
1
+ import React, {
2
+ forwardRef,
3
+ useCallback,
4
+ useImperativeHandle,
5
+ useRef,
6
+ useState,
7
+ } from "react";
8
+ import {
9
+ TextInput,
10
+ InputProps as TextInputProps,
11
+ } from "../TextInput/TextInput";
12
+ import { ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
13
+ import { cn } from "@/utils/cn";
14
+
15
+ export type NumberInputProps = Omit<
16
+ TextInputProps,
17
+ "type" | "value" | "defaultValue" | "onChange"
18
+ > & {
19
+ value?: number | string;
20
+ defaultValue?: number | string;
21
+ min?: number;
22
+ max?: number;
23
+ step?: number;
24
+ precision?: number;
25
+ hideControls?: boolean;
26
+ allowDecimal?: boolean;
27
+ allowNegative?: boolean;
28
+ formatDisplay?: boolean;
29
+ thousandSeparator?: string;
30
+ decimalSeparator?: string;
31
+ prefix?: string;
32
+ suffix?: string;
33
+ onChange?: (value: number | undefined) => void;
34
+ onValueChange?: (value: string) => void;
35
+ };
36
+
37
+ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
38
+ (
39
+ {
40
+ value,
41
+ defaultValue,
42
+ min,
43
+ max,
44
+ step = 1,
45
+ precision,
46
+ hideControls = false,
47
+ allowDecimal = true,
48
+ allowNegative = true,
49
+ formatDisplay = false,
50
+ thousandSeparator = ",",
51
+ decimalSeparator = ".",
52
+ prefix = "",
53
+ suffix = "",
54
+ disabled = false,
55
+ onChange,
56
+ onValueChange,
57
+ className,
58
+ size = "md",
59
+ ...props
60
+ },
61
+ ref
62
+ ) => {
63
+ const inputRef = useRef<HTMLInputElement>(null);
64
+ const [internalValue, setInternalValue] = useState<string>(
65
+ (value ?? defaultValue ?? "").toString()
66
+ );
67
+ const [isFocused, setIsFocused] = useState(false);
68
+
69
+ useImperativeHandle(ref, () => inputRef?.current as HTMLInputElement);
70
+
71
+ // Helper function to remove formatting
72
+ const unformatNumber = useCallback(
73
+ (formattedValue: string): string => {
74
+ let cleaned = formattedValue;
75
+
76
+ // Remove prefix and suffix
77
+ if (prefix) cleaned = cleaned.replace(prefix, "");
78
+ if (suffix) cleaned = cleaned.replace(suffix, "");
79
+
80
+ // Remove thousand separators
81
+ if (thousandSeparator) {
82
+ cleaned = cleaned.split(thousandSeparator).join("");
83
+ }
84
+
85
+ // Replace decimal separator with standard dot
86
+ if (decimalSeparator !== ".") {
87
+ cleaned = cleaned.replace(decimalSeparator, ".");
88
+ }
89
+
90
+ return cleaned.trim();
91
+ },
92
+ [prefix, suffix, thousandSeparator, decimalSeparator]
93
+ );
94
+
95
+ // Helper function to format number for display
96
+ const formatNumber = useCallback(
97
+ (numValue: string): string => {
98
+ if (!formatDisplay || numValue === "" || numValue === "-")
99
+ return numValue;
100
+
101
+ const num = parseFloat(numValue);
102
+ if (isNaN(num)) return numValue;
103
+
104
+ let formatted = num.toString();
105
+
106
+ // Apply precision formatting
107
+ if (precision !== undefined) {
108
+ formatted = num.toFixed(precision);
109
+ }
110
+
111
+ // Split into integer and decimal parts
112
+ const parts = formatted.split(".");
113
+ let integerPart = parts[0];
114
+ const decimalPart = parts[1];
115
+
116
+ // Add thousand separators
117
+ if (thousandSeparator) {
118
+ integerPart = integerPart.replace(
119
+ /\B(?=(\d{3})+(?!\d))/g,
120
+ thousandSeparator
121
+ );
122
+ }
123
+
124
+ // Combine with decimal separator
125
+ formatted = decimalPart
126
+ ? `${integerPart}${decimalSeparator}${decimalPart}`
127
+ : integerPart;
128
+
129
+ // Add prefix and suffix
130
+ if (prefix) formatted = `${prefix}${formatted}`;
131
+ if (suffix) formatted = `${formatted}${suffix}`;
132
+
133
+ return formatted;
134
+ },
135
+ [
136
+ formatDisplay,
137
+ precision,
138
+ thousandSeparator,
139
+ decimalSeparator,
140
+ prefix,
141
+ suffix,
142
+ ]
143
+ );
144
+
145
+ const validateAndFormat = useCallback(
146
+ (val: string): string => {
147
+ if (val === "" || val === "-") return val;
148
+
149
+ let numValue = parseFloat(val);
150
+
151
+ if (isNaN(numValue)) return internalValue;
152
+
153
+ // Apply min/max constraints
154
+ if (min !== undefined && numValue < min) numValue = min;
155
+ if (max !== undefined && numValue > max) numValue = max;
156
+
157
+ // Apply precision
158
+ if (precision !== undefined) {
159
+ numValue = parseFloat(numValue.toFixed(precision));
160
+ }
161
+
162
+ return numValue.toString();
163
+ },
164
+ [min, max, precision, internalValue]
165
+ );
166
+
167
+ const handleInputChange = useCallback(
168
+ (e: React.ChangeEvent<HTMLInputElement>) => {
169
+ let inputValue = e.target.value;
170
+
171
+ // Unformat the input if formatting is enabled
172
+ if (formatDisplay) {
173
+ inputValue = unformatNumber(inputValue);
174
+ }
175
+
176
+ // Allow empty input
177
+ if (inputValue === "") {
178
+ setInternalValue("");
179
+ onChange?.(undefined);
180
+ onValueChange?.("");
181
+ return;
182
+ }
183
+
184
+ // Allow negative sign at the start
185
+ if (allowNegative && inputValue === "-") {
186
+ setInternalValue("-");
187
+ onValueChange?.("-");
188
+ return;
189
+ }
190
+
191
+ // Filter non-numeric characters based on settings
192
+ const regex = allowDecimal
193
+ ? allowNegative
194
+ ? /^-?\d*\.?\d*$/
195
+ : /^\d*\.?\d*$/
196
+ : allowNegative
197
+ ? /^-?\d*$/
198
+ : /^\d*$/;
199
+
200
+ if (!regex.test(inputValue)) {
201
+ return;
202
+ }
203
+
204
+ setInternalValue(inputValue);
205
+ onValueChange?.(inputValue);
206
+
207
+ // Only call onChange with a valid number
208
+ const numValue = parseFloat(inputValue);
209
+ if (!isNaN(numValue)) {
210
+ onChange?.(numValue);
211
+ }
212
+ },
213
+ [
214
+ allowDecimal,
215
+ allowNegative,
216
+ formatDisplay,
217
+ unformatNumber,
218
+ onChange,
219
+ onValueChange,
220
+ ]
221
+ );
222
+
223
+ const handleFocus = useCallback(
224
+ (e: React.FocusEvent<HTMLInputElement>) => {
225
+ setIsFocused(true);
226
+ props.onFocus?.(e);
227
+ },
228
+ [props]
229
+ );
230
+
231
+ const handleBlur = useCallback(
232
+ (e: React.FocusEvent<HTMLInputElement>) => {
233
+ setIsFocused(false);
234
+
235
+ const formatted = validateAndFormat(internalValue);
236
+ setInternalValue(formatted);
237
+
238
+ const numValue = parseFloat(formatted);
239
+ if (!isNaN(numValue)) {
240
+ onChange?.(numValue);
241
+ }
242
+
243
+ props.onBlur?.(e);
244
+ },
245
+ [internalValue, validateAndFormat, onChange, props]
246
+ );
247
+
248
+ const increment = useCallback(
249
+ (
250
+ e:
251
+ | React.MouseEvent<HTMLButtonElement>
252
+ | React.KeyboardEvent<HTMLInputElement>
253
+ ) => {
254
+ if (disabled) return;
255
+
256
+ e.stopPropagation();
257
+ e.preventDefault();
258
+
259
+ const currentValue = parseFloat(internalValue) || 0;
260
+ let newValue = currentValue + step;
261
+
262
+ if (max !== undefined && newValue > max) {
263
+ newValue = max;
264
+ }
265
+
266
+ const formatted = validateAndFormat(newValue.toString());
267
+ setInternalValue(formatted);
268
+ onChange?.(parseFloat(formatted));
269
+ onValueChange?.(formatted);
270
+ },
271
+ [
272
+ internalValue,
273
+ step,
274
+ max,
275
+ disabled,
276
+ validateAndFormat,
277
+ onChange,
278
+ onValueChange,
279
+ ]
280
+ );
281
+
282
+ const decrement = useCallback(
283
+ (
284
+ e:
285
+ | React.MouseEvent<HTMLButtonElement>
286
+ | React.KeyboardEvent<HTMLInputElement>
287
+ ) => {
288
+ if (disabled) return;
289
+
290
+ e.stopPropagation();
291
+ e.preventDefault();
292
+
293
+ const currentValue = parseFloat(internalValue) || 0;
294
+ let newValue = currentValue - step;
295
+
296
+ if (min !== undefined && newValue < min) {
297
+ newValue = min;
298
+ }
299
+
300
+ const formatted = validateAndFormat(newValue.toString());
301
+ setInternalValue(formatted);
302
+ onChange?.(parseFloat(formatted));
303
+ onValueChange?.(formatted);
304
+ },
305
+ [
306
+ internalValue,
307
+ step,
308
+ min,
309
+ disabled,
310
+ validateAndFormat,
311
+ onChange,
312
+ onValueChange,
313
+ ]
314
+ );
315
+
316
+ const handleKeyDown = useCallback(
317
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
318
+ switch (e.key) {
319
+ case "ArrowUp":
320
+ increment(e);
321
+ break;
322
+ case "ArrowDown":
323
+ decrement(e);
324
+ break;
325
+ case "Enter":
326
+ e.currentTarget.blur();
327
+ break;
328
+ default:
329
+ break;
330
+ }
331
+ props.onKeyDown?.(e);
332
+ },
333
+ [increment, decrement, props]
334
+ );
335
+
336
+ // Sync internal value with external value prop
337
+ React.useEffect(() => {
338
+ if (value !== undefined) {
339
+ setInternalValue(value.toString());
340
+ }
341
+ }, [value]);
342
+
343
+ // Get the display value (formatted when not focused)
344
+ const displayValue = React.useMemo(() => {
345
+ if (isFocused || !formatDisplay) {
346
+ return internalValue;
347
+ }
348
+ return formatNumber(internalValue);
349
+ }, [isFocused, formatDisplay, internalValue, formatNumber]);
350
+
351
+ const controlsSize = {
352
+ sm: "w-3 h-3",
353
+ md: "w-4 h-4",
354
+ lg: "w-6 h-6",
355
+ }[size];
356
+
357
+ const paddingSize = {
358
+ sm: "absolute top-0.5",
359
+ md: "absolute top-1",
360
+ lg: "absolute top-1",
361
+ }[size];
362
+
363
+ const renderControls = () => {
364
+ if (hideControls) return null;
365
+
366
+ return (
367
+ <div
368
+ className={cn(
369
+ "flex flex-col",
370
+ props.iconMode === "flat" && paddingSize
371
+ )}
372
+ >
373
+ <button
374
+ type="button"
375
+ onClick={increment}
376
+ disabled={
377
+ disabled ||
378
+ (max !== undefined && parseFloat(internalValue) >= max)
379
+ }
380
+ className={cn(
381
+ " hover:bg-input-active-stroke/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-full text-input-filled-text"
382
+ )}
383
+ tabIndex={-1}
384
+ >
385
+ <ChevronUpIcon className={controlsSize} />
386
+ </button>
387
+ <button
388
+ type="button"
389
+ onClick={decrement}
390
+ disabled={
391
+ disabled ||
392
+ (min !== undefined && parseFloat(internalValue) <= min)
393
+ }
394
+ className={cn(
395
+ " hover:bg-input-active-stroke/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-full text-input-filled-text"
396
+ )}
397
+ tabIndex={-1}
398
+ >
399
+ <ChevronDownIcon className={controlsSize} />
400
+ </button>
401
+ </div>
402
+ );
403
+ };
404
+
405
+ return (
406
+ <TextInput
407
+ {...props}
408
+ ref={inputRef}
409
+ type="text"
410
+ inputMode="decimal"
411
+ value={displayValue}
412
+ onChange={handleInputChange as any}
413
+ onFocus={handleFocus}
414
+ onBlur={handleBlur}
415
+ onKeyDown={handleKeyDown}
416
+ disabled={disabled}
417
+ size={size}
418
+ hasClearIcon={false}
419
+ endIcon={renderControls()}
420
+ className={cn(className)}
421
+ />
422
+ );
423
+ }
424
+ );
425
+
426
+ NumberInput.displayName = "NumberInput";
427
+
428
+ export default NumberInput;
@@ -0,0 +1,2 @@
1
+ export { NumberInput, default } from "./NumberInput";
2
+ export type { NumberInputProps } from "./NumberInput";
@@ -45,6 +45,13 @@ export type InputProps = {
45
45
  endIcon?: ReactNode;
46
46
  className?: string;
47
47
  labelClassName?: string;
48
+ classes?: {
49
+ iconWrapper?: string;
50
+ iconSearchWrapper?: string;
51
+ icon?: string;
52
+ startIconWrapper?: string;
53
+ endIconWrapper?: string;
54
+ };
48
55
  onClickStartIcon?: () => void;
49
56
  onClickEndIcon?: () => void;
50
57
  renderStartIcon?: () => ReactNode;
@@ -78,6 +85,7 @@ export const TextInput = forwardRef<HTMLInputElement, InputProps>(
78
85
  onClickEndIcon,
79
86
  renderStartIcon,
80
87
  renderEndIcon,
88
+ classes,
81
89
  ...props
82
90
  },
83
91
  ref
@@ -164,13 +172,30 @@ export const TextInput = forwardRef<HTMLInputElement, InputProps>(
164
172
  const startIconElement = useMemo(() => {
165
173
  if (!hasLeftSectionIcon) return;
166
174
 
167
- if (renderStartIcon) return renderStartIcon();
175
+ if (renderStartIcon) {
176
+ return (
177
+ <div
178
+ className={cn(
179
+ iconSearchWrapperClassname,
180
+ "flex",
181
+ classes?.iconSearchWrapper
182
+ )}
183
+ >
184
+ {renderStartIcon()}
185
+ </div>
186
+ );
187
+ }
168
188
 
169
189
  if (iconMode === "flat") {
170
190
  return (
171
- <div className={iconSearchWrapperClassname}>
191
+ <div
192
+ className={cn(
193
+ iconSearchWrapperClassname,
194
+ classes?.iconSearchWrapper
195
+ )}
196
+ >
172
197
  <div
173
- className={iconClassname}
198
+ className={cn(iconClassname, classes?.icon)}
174
199
  onClick={handleOnClickLeftSectionIcon}
175
200
  >
176
201
  {startIcon}
@@ -181,7 +206,7 @@ export const TextInput = forwardRef<HTMLInputElement, InputProps>(
181
206
 
182
207
  return (
183
208
  <div
184
- className={startIconWrapperClassname}
209
+ className={cn(startIconWrapperClassname, classes?.startIconWrapper)}
185
210
  onClick={handleOnClickLeftSectionIcon}
186
211
  >
187
212
  {startIcon}
@@ -201,13 +226,23 @@ export const TextInput = forwardRef<HTMLInputElement, InputProps>(
201
226
  const endIconElement = useMemo(() => {
202
227
  if (!hasRightSectionIcon) return;
203
228
 
204
- if (renderEndIcon) return renderEndIcon();
229
+ if (renderEndIcon) {
230
+ return (
231
+ <div
232
+ className={cn(iconWrapperClassname, "flex", classes?.iconWrapper)}
233
+ >
234
+ {renderEndIcon()}
235
+ </div>
236
+ );
237
+ }
205
238
 
206
239
  if (iconMode === "flat") {
207
240
  return (
208
- <div className={cn(iconWrapperClassname, "flex")}>
241
+ <div
242
+ className={cn(iconWrapperClassname, "flex", classes?.iconWrapper)}
243
+ >
209
244
  <div
210
- className={iconClassname}
245
+ className={cn(iconClassname, classes?.icon)}
211
246
  onClick={handleOnClickRightSectionIcon}
212
247
  >
213
248
  {endIcon}
@@ -218,7 +253,7 @@ export const TextInput = forwardRef<HTMLInputElement, InputProps>(
218
253
 
219
254
  return (
220
255
  <div
221
- className={endIconWrapperClassname}
256
+ className={cn(endIconWrapperClassname, classes?.endIconWrapper)}
222
257
  onClick={handleOnClickRightSectionIcon}
223
258
  >
224
259
  {endIcon}
@@ -239,8 +274,15 @@ export const TextInput = forwardRef<HTMLInputElement, InputProps>(
239
274
  <div className={`inline-flex flex-col ${fullwidth ? "w-full" : ""}`}>
240
275
  <div className="relative">
241
276
  {hasSearchIcon && !hasLeftSectionIcon && (
242
- <div className={iconSearchWrapperClassname}>
243
- <MagnifyingGlassIcon className={iconClassname} />
277
+ <div
278
+ className={cn(
279
+ iconSearchWrapperClassname,
280
+ classes?.iconSearchWrapper
281
+ )}
282
+ >
283
+ <MagnifyingGlassIcon
284
+ className={cn(iconClassname, classes?.icon)}
285
+ />
244
286
  </div>
245
287
  )}
246
288
  <input
@@ -256,7 +298,7 @@ export const TextInput = forwardRef<HTMLInputElement, InputProps>(
256
298
 
257
299
  {hasClearIcon && !hasRightSectionIcon && (
258
300
  <div
259
- className={iconWrapperClassname}
301
+ className={cn(iconWrapperClassname, classes?.iconWrapper)}
260
302
  style={{
261
303
  display:
262
304
  keepCloseIconOnValue && props.value ? "flex" : undefined,
@@ -264,7 +306,7 @@ export const TextInput = forwardRef<HTMLInputElement, InputProps>(
264
306
  >
265
307
  <XCircleIcon
266
308
  type="button"
267
- className={iconClassname}
309
+ className={cn(iconClassname, classes?.icon)}
268
310
  onMouseDown={handleClearInput}
269
311
  />
270
312
  </div>
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import "./icons/iconConfig";
5
5
 
6
6
  export { default as Button } from "./components/Button/Button";
7
7
  export { default as TextInput } from "./components/TextInput/TextInput";
8
+ export { NumberInput } from "./components/NumberInput/NumberInput";
8
9
  export { default as TextArea } from "./components/TextArea/TextArea";
9
10
  export { default as Text } from "./components/Text/Text";
10
11
  export { default as Tabs } from "./components/Tabs/Tabs";
@@ -47,6 +48,7 @@ export * from "./components/RadioGroup/RadioGroup";
47
48
  // Export component types
48
49
  export type { ButtonProps } from "./components/Button/Button";
49
50
  export type { InputProps } from "./components/TextInput/TextInput";
51
+ export type { NumberInputProps } from "./components/NumberInput/NumberInput";
50
52
  export type { TextAreaProps } from "./components/TextArea/TextArea";
51
53
  export type { DropdownProps, Options } from "./components/Dropdown/Dropdown";
52
54
  export type { NavbarProps } from "./components/Navbar/Navbar";