@axzydev/axzy_ui_system 1.3.4 → 1.3.6
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/README.md +1 -1
- package/dist/index.cjs +754 -613
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +148 -5
- package/dist/index.d.ts +148 -5
- package/dist/index.js +628 -488
- package/dist/index.js.map +1 -1
- package/package.json +9 -3
- package/src/App.tsx +6 -0
- package/src/components/atoms/input/input.props.ts +5 -0
- package/src/components/atoms/input/input.tsx +24 -17
- package/src/components/atoms/input-number/input-number.doc.mdx +74 -0
- package/src/components/atoms/input-number/input-number.props.ts +66 -0
- package/src/components/atoms/input-number/input-number.stories.tsx +111 -0
- package/src/components/atoms/input-number/input-number.tsx +86 -0
- package/src/components/molecules/table/table.doc.mdx +12 -1
- package/src/components/molecules/table/table.props.ts +17 -0
- package/src/components/molecules/table/table.stories.tsx +45 -0
- package/src/components/molecules/table/table.tsx +37 -8
- package/src/components/organisms/data-table/ITDataTable.doc.mdx +14 -1
- package/src/components/organisms/data-table/ITDataTable.stories.tsx +25 -0
- package/src/components/organisms/data-table/dataTable.tsx +37 -8
- package/src/components/organisms/sidebar/sidebar.doc.mdx +50 -12
- package/src/components/organisms/sidebar/sidebar.props.ts +28 -2
- package/src/components/organisms/sidebar/sidebar.stories.tsx +61 -1
- package/src/components/organisms/sidebar/sidebar.tsx +111 -57
- package/src/index.css +10 -0
- package/src/index.ts +6 -3
- package/src/sandbox/navigation.ts +2 -0
- package/src/showcases/DataShowcases.tsx +61 -0
- package/src/showcases/FormShowcases.tsx +287 -1
- package/src/showcases/StructureShowcases.tsx +34 -6
- package/src/utils/table.utils.ts +83 -0
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"packageManager": "pnpm@10.34.0",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"version": "1.3.
|
|
7
|
+
"version": "1.3.6",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"sideEffects": [
|
|
10
10
|
"*.css"
|
|
@@ -38,7 +38,11 @@
|
|
|
38
38
|
"bundle": "tsup src/index.ts && node scripts/build-css.mjs",
|
|
39
39
|
"watch": "tsup src/index.ts --watch",
|
|
40
40
|
"storybook": "storybook dev -p 6006",
|
|
41
|
-
"build-storybook": "storybook build"
|
|
41
|
+
"build-storybook": "storybook build",
|
|
42
|
+
"ext:catalog": "node vscode-extension/scripts/generate-catalog.mjs",
|
|
43
|
+
"ext:build": "node vscode-extension/esbuild.mjs",
|
|
44
|
+
"ext:typecheck": "tsc --noEmit -p vscode-extension/tsconfig.json",
|
|
45
|
+
"ext:package": "node vscode-extension/esbuild.mjs && cd vscode-extension && npx --yes @vscode/vsce package --no-dependencies"
|
|
42
46
|
},
|
|
43
47
|
"dependencies": {
|
|
44
48
|
"clsx": "^2.1.1",
|
|
@@ -81,6 +85,8 @@
|
|
|
81
85
|
"vitest": "^4.0.18",
|
|
82
86
|
"playwright": "^1.58.0",
|
|
83
87
|
"@vitest/browser-playwright": "^4.0.18",
|
|
84
|
-
"@vitest/coverage-v8": "^4.0.18"
|
|
88
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
89
|
+
"@types/vscode": "1.85.0",
|
|
90
|
+
"esbuild": "^0.25.0"
|
|
85
91
|
}
|
|
86
92
|
}
|
package/src/App.tsx
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
import {
|
|
36
36
|
ButtonShowcase,
|
|
37
37
|
InputShowcase,
|
|
38
|
+
InputNumberShowcase,
|
|
38
39
|
SelectShowcase,
|
|
39
40
|
SearchSelectShowcase,
|
|
40
41
|
DatePickerShowcase,
|
|
@@ -48,6 +49,7 @@ import {
|
|
|
48
49
|
MaskedInputShowcase,
|
|
49
50
|
MultiSelectShowcase,
|
|
50
51
|
ChipInputShowcase,
|
|
52
|
+
SegmentedControlShowcase,
|
|
51
53
|
} from "./showcases/FormShowcases";
|
|
52
54
|
import {
|
|
53
55
|
TableShowcase,
|
|
@@ -261,6 +263,8 @@ function App() {
|
|
|
261
263
|
return <ButtonShowcase />;
|
|
262
264
|
case "input":
|
|
263
265
|
return <InputShowcase />;
|
|
266
|
+
case "inputnumber":
|
|
267
|
+
return <InputNumberShowcase />;
|
|
264
268
|
case "select":
|
|
265
269
|
return <SelectShowcase />;
|
|
266
270
|
case "searchselect":
|
|
@@ -281,6 +285,8 @@ function App() {
|
|
|
281
285
|
return <CalendarShowcase />;
|
|
282
286
|
case "slidetoggle":
|
|
283
287
|
return <SlideToggleShowcase />;
|
|
288
|
+
case "segmentedcontrol":
|
|
289
|
+
return <SegmentedControlShowcase />;
|
|
284
290
|
case "dropfile":
|
|
285
291
|
return <DropfileShowcase />;
|
|
286
292
|
case "wysiwyg":
|
|
@@ -18,6 +18,11 @@ export interface ITInputProps {
|
|
|
18
18
|
label?: string;
|
|
19
19
|
/** Enable currency formatting for number inputs (MX locale) */
|
|
20
20
|
currencyFormat?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Decimal places allowed for number inputs. `0` restricts to integers.
|
|
23
|
+
* @default currencyFormat ? 2 : 0
|
|
24
|
+
*/
|
|
25
|
+
decimals?: number;
|
|
21
26
|
/** Placeholder text */
|
|
22
27
|
placeholder?: string;
|
|
23
28
|
/** Current input value */
|
|
@@ -66,11 +66,14 @@ export default function ITInput({
|
|
|
66
66
|
max,
|
|
67
67
|
readOnly = false,
|
|
68
68
|
focusContent,
|
|
69
|
-
size = "md"
|
|
69
|
+
size = "md",
|
|
70
|
+
decimals
|
|
70
71
|
}: ITInputProps) {
|
|
71
72
|
const isCheckboxOrRadio = type === "checkbox" || type === "radio";
|
|
72
73
|
const isNumberType = type === "number";
|
|
73
74
|
const isTextArea = type === "textarea";
|
|
75
|
+
// Currency inputs default to 2 decimals; plain numbers to integers.
|
|
76
|
+
const decimalPlaces = decimals ?? (currencyFormat ? 2 : 0);
|
|
74
77
|
|
|
75
78
|
const [displayValue, setDisplayValue] = useState<string>("");
|
|
76
79
|
const [isFocused, setIsFocused] = useState(false);
|
|
@@ -146,14 +149,18 @@ export default function ITInput({
|
|
|
146
149
|
|
|
147
150
|
if (currencyFormat) {
|
|
148
151
|
return num.toLocaleString("es-MX", {
|
|
149
|
-
minimumFractionDigits:
|
|
150
|
-
maximumFractionDigits:
|
|
152
|
+
minimumFractionDigits: decimalPlaces,
|
|
153
|
+
maximumFractionDigits: decimalPlaces,
|
|
151
154
|
});
|
|
152
155
|
}
|
|
153
156
|
|
|
157
|
+
if (decimalPlaces > 0) {
|
|
158
|
+
return num.toFixed(decimalPlaces);
|
|
159
|
+
}
|
|
160
|
+
|
|
154
161
|
return num.toString();
|
|
155
162
|
},
|
|
156
|
-
[currencyFormat]
|
|
163
|
+
[currencyFormat, decimalPlaces]
|
|
157
164
|
);
|
|
158
165
|
|
|
159
166
|
const unformatValue = useCallback(
|
|
@@ -204,13 +211,13 @@ useEffect(() => {
|
|
|
204
211
|
return;
|
|
205
212
|
}
|
|
206
213
|
|
|
207
|
-
if (
|
|
214
|
+
if (decimalPlaces === 0 && (key === "." || key === ",")) {
|
|
208
215
|
e.preventDefault();
|
|
209
216
|
return;
|
|
210
217
|
}
|
|
211
218
|
|
|
212
219
|
if (
|
|
213
|
-
|
|
220
|
+
decimalPlaces > 0 &&
|
|
214
221
|
(key === "." || key === ",") &&
|
|
215
222
|
currentValue.includes(".")
|
|
216
223
|
) {
|
|
@@ -223,7 +230,7 @@ useEffect(() => {
|
|
|
223
230
|
}
|
|
224
231
|
}
|
|
225
232
|
|
|
226
|
-
const allowedCharsRegex =
|
|
233
|
+
const allowedCharsRegex = decimalPlaces > 0 ? /^[0-9.,]$/ : /^[0-9]$/;
|
|
227
234
|
// If it's a mobile key event like Unidentified, we bypass the regex check safely
|
|
228
235
|
if (key !== "Unidentified" && !allowedCharsRegex.test(key)) {
|
|
229
236
|
e.preventDefault();
|
|
@@ -256,7 +263,7 @@ const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
256
263
|
let rawValue = e.target.value;
|
|
257
264
|
let cleanedValue = "";
|
|
258
265
|
|
|
259
|
-
if (
|
|
266
|
+
if (decimalPlaces > 0) {
|
|
260
267
|
if (rawValue.includes(",") && rawValue.includes(".")) {
|
|
261
268
|
rawValue = rawValue.replace(/,/g, "");
|
|
262
269
|
} else if (rawValue.includes(",")) {
|
|
@@ -266,9 +273,9 @@ const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
266
273
|
cleanedValue = rawValue.replace(/[^0-9.]/g, "");
|
|
267
274
|
const parts = cleanedValue.split(".");
|
|
268
275
|
if (parts.length > 1) {
|
|
269
|
-
// Keep only first dot
|
|
270
|
-
const
|
|
271
|
-
cleanedValue = parts[0] + "." +
|
|
276
|
+
// Keep only the first dot and restrict decimals to the allowed digits
|
|
277
|
+
const decimalPart = parts.slice(1).join("").substring(0, decimalPlaces);
|
|
278
|
+
cleanedValue = parts[0] + "." + decimalPart;
|
|
272
279
|
}
|
|
273
280
|
} else {
|
|
274
281
|
cleanedValue = rawValue.replace(/[^0-9]/g, "");
|
|
@@ -282,7 +289,7 @@ const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
282
289
|
if (!formatNumber) {
|
|
283
290
|
valueToSend = cleanedValue;
|
|
284
291
|
} else if (cleanedValue !== "") {
|
|
285
|
-
if (
|
|
292
|
+
if (decimalPlaces > 0) {
|
|
286
293
|
const numericValue = parseFloat(cleanedValue);
|
|
287
294
|
if (!isNaN(numericValue)) {
|
|
288
295
|
// ALWAYS send string representation to avoid dropping trailing decimals
|
|
@@ -374,7 +381,7 @@ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
|
374
381
|
let valueToSend: number | string | undefined = undefined;
|
|
375
382
|
|
|
376
383
|
let cleanedValue = "";
|
|
377
|
-
if (
|
|
384
|
+
if (decimalPlaces > 0) {
|
|
378
385
|
cleanedValue = currentValue.replace(/[^0-9.]/g, "");
|
|
379
386
|
const parts = cleanedValue.split(".");
|
|
380
387
|
if (parts.length > 2) {
|
|
@@ -385,7 +392,7 @@ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
|
385
392
|
cleanedValue = currentValue.replace(/[^0-9]/g, "");
|
|
386
393
|
}
|
|
387
394
|
|
|
388
|
-
const parsed =
|
|
395
|
+
const parsed = decimalPlaces > 0
|
|
389
396
|
? parseFloat(cleanedValue)
|
|
390
397
|
: parseInt(cleanedValue, 10);
|
|
391
398
|
|
|
@@ -430,7 +437,7 @@ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
|
430
437
|
};
|
|
431
438
|
|
|
432
439
|
const currentLength = isNumberType
|
|
433
|
-
? (
|
|
440
|
+
? (decimalPlaces > 0 ? displayValue.replace(/[.,]/g, "") : displayValue).length
|
|
434
441
|
: typeof value === "string"
|
|
435
442
|
? value.length
|
|
436
443
|
: String(value ?? "").length;
|
|
@@ -541,7 +548,7 @@ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
|
541
548
|
}
|
|
542
549
|
inputMode={
|
|
543
550
|
isNumberType
|
|
544
|
-
?
|
|
551
|
+
? decimalPlaces > 0
|
|
545
552
|
? "decimal"
|
|
546
553
|
: "numeric"
|
|
547
554
|
: undefined
|
|
@@ -562,7 +569,7 @@ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
|
562
569
|
}
|
|
563
570
|
onKeyDown={isNumberType ? handleKeyDown : onKeyDown}
|
|
564
571
|
readOnly={readOnly}
|
|
565
|
-
maxLength={isNumberType &&
|
|
572
|
+
maxLength={isNumberType && decimalPlaces === 0 ? maxLength : undefined}
|
|
566
573
|
minLength={minLength}
|
|
567
574
|
min={min}
|
|
568
575
|
max={max}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Meta, Title, Description, Primary, Controls, Stories } from '@storybook/blocks';
|
|
2
|
+
import * as StoriesData from './input-number.stories';
|
|
3
|
+
|
|
4
|
+
<Meta of={StoriesData} />
|
|
5
|
+
|
|
6
|
+
# ITInputNumber
|
|
7
|
+
|
|
8
|
+
Numeric field that always renders currency-style formatting (thousand separators + fixed decimals) and emits a real `number` through `onChange` / `onBlur`. Built on top of `ITInput`, so it inherits label, validation, sizes, icons and theming.
|
|
9
|
+
|
|
10
|
+
## Props
|
|
11
|
+
|
|
12
|
+
| Prop | Type | Default | Description |
|
|
13
|
+
|------|------|---------|-------------|
|
|
14
|
+
| `name` | `string` | required | Field name used for form identification. |
|
|
15
|
+
| `value` | `number \| null` | `undefined` | Current numeric value. |
|
|
16
|
+
| `onChange` | `(value: number \| undefined, event) => void` | required | Called with the parsed value on every change. |
|
|
17
|
+
| `onBlur` | `(value: number \| undefined, event) => void` | `undefined` | Called on blur with the parsed (and clamped) value. |
|
|
18
|
+
| `decimals` | `number` | `2` | Decimal places allowed. `0` restricts to integers. |
|
|
19
|
+
| `prefix` | `string` | `undefined` | Text prefix rendered inside the field (e.g. `"$"`). Ignored when `iconLeft` is set. |
|
|
20
|
+
| `label` | `string` | `undefined` | Label displayed above the input. |
|
|
21
|
+
| `placeholder` | `string` | `undefined` | Placeholder text. |
|
|
22
|
+
| `min` | `number` | `undefined` | Minimum value; lower values are clamped on blur. |
|
|
23
|
+
| `max` | `number` | `undefined` | Maximum value; higher values are clamped on blur. |
|
|
24
|
+
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Size preset. |
|
|
25
|
+
| `disabled` | `boolean` | `false` | Disable the input. |
|
|
26
|
+
| `readOnly` | `boolean` | `false` | Render the input in read-only mode. |
|
|
27
|
+
| `required` | `boolean` | `false` | Mark the field as required. |
|
|
28
|
+
| `touched` | `boolean` | `undefined` | Whether the field has been touched by the user. |
|
|
29
|
+
| `error` | `string \| boolean` | `undefined` | Validation error (`true` = generic message). |
|
|
30
|
+
| `iconLeft` / `iconRight` | `ReactNode` | `undefined` | Icons rendered inside the field. |
|
|
31
|
+
| `containerClassName` | `string` | `undefined` | Additional classes for the outer container. |
|
|
32
|
+
| `labelClassName` | `string` | `undefined` | Additional classes for the label. |
|
|
33
|
+
| `className` | `string` | `undefined` | Additional classes for the input element. |
|
|
34
|
+
| `autoFocus` | `boolean` | `false` | Auto-focus on mount. |
|
|
35
|
+
| `focusContent` | `boolean` | `false` | Select all content on click. |
|
|
36
|
+
| `onKeyDown` | `(event) => void` | `undefined` | Keydown handler for the underlying input. |
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { ITInputNumber } from '@axzydev/axzy_ui_system';
|
|
42
|
+
|
|
43
|
+
<ITInputNumber
|
|
44
|
+
name="amount"
|
|
45
|
+
label="Monto"
|
|
46
|
+
value={amount}
|
|
47
|
+
prefix="$"
|
|
48
|
+
onChange={(value) => setAmount(value)}
|
|
49
|
+
/>
|
|
50
|
+
|
|
51
|
+
<ITInputNumber
|
|
52
|
+
name="qty"
|
|
53
|
+
label="Cantidad"
|
|
54
|
+
decimals={0}
|
|
55
|
+
min={1}
|
|
56
|
+
max={99}
|
|
57
|
+
value={qty}
|
|
58
|
+
onChange={(value) => setQty(value)}
|
|
59
|
+
/>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Notes
|
|
63
|
+
|
|
64
|
+
- The emitted value is a `number` (or `undefined` when the field is empty) — no `parseFloat` needed by the consumer.
|
|
65
|
+
- Formatting is always on: thousand separators while blurred, raw digits while typing.
|
|
66
|
+
- Use `decimals={0}` for integers only.
|
|
67
|
+
- Values are clamped to `min`/`max` on blur.
|
|
68
|
+
- `prefix` is a convenience over `iconLeft`; pass `iconLeft` directly for any custom node.
|
|
69
|
+
|
|
70
|
+
## Stories
|
|
71
|
+
|
|
72
|
+
<Primary />
|
|
73
|
+
<Controls />
|
|
74
|
+
<Stories />
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ChangeEvent, FocusEvent, KeyboardEvent, ReactNode } from "react";
|
|
2
|
+
import { SizesTypes } from "@/types/sizes.types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Props for the ITInputNumber component.
|
|
6
|
+
*
|
|
7
|
+
* Same surface as `ITInput` for numeric entry, but the value is always
|
|
8
|
+
* formatted as currency (thousand separators + decimals) and emitted as a
|
|
9
|
+
* real `number` instead of a string.
|
|
10
|
+
*/
|
|
11
|
+
export interface ITInputNumberProps {
|
|
12
|
+
/** Field name used for form identification. */
|
|
13
|
+
name: string;
|
|
14
|
+
/** Current numeric value. Pass `null`/`undefined` for "empty". */
|
|
15
|
+
value?: number | null;
|
|
16
|
+
/**
|
|
17
|
+
* Called with the parsed value on every change.
|
|
18
|
+
* Receives `undefined` when the field is empty, plus the original input event.
|
|
19
|
+
*/
|
|
20
|
+
onChange: (value: number | undefined, event: ChangeEvent<HTMLInputElement>) => void;
|
|
21
|
+
/**
|
|
22
|
+
* Called on blur with the parsed (and min/max clamped) value, plus the
|
|
23
|
+
* original focus event.
|
|
24
|
+
*/
|
|
25
|
+
onBlur?: (value: number | undefined, event: FocusEvent<HTMLInputElement>) => void;
|
|
26
|
+
/** Number of decimal places allowed. `0` restricts to integers. @default 2 */
|
|
27
|
+
decimals?: number;
|
|
28
|
+
/** Text prefix rendered inside the field, e.g. `"$"`. Ignored when `iconLeft` is set. */
|
|
29
|
+
prefix?: string;
|
|
30
|
+
/** Label displayed above the input. */
|
|
31
|
+
label?: string;
|
|
32
|
+
/** Placeholder text. */
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
/** Minimum numeric value. Values below are clamped on blur. */
|
|
35
|
+
min?: number;
|
|
36
|
+
/** Maximum numeric value. Values above are clamped on blur. */
|
|
37
|
+
max?: number;
|
|
38
|
+
/** Size preset: "sm" | "md" | "lg". @default "md" */
|
|
39
|
+
size?: SizesTypes;
|
|
40
|
+
/** Disable the input. @default false */
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
/** Render the input in read-only mode. @default false */
|
|
43
|
+
readOnly?: boolean;
|
|
44
|
+
/** Mark the field as required. @default false */
|
|
45
|
+
required?: boolean;
|
|
46
|
+
/** Whether the field has been touched by the user. */
|
|
47
|
+
touched?: boolean;
|
|
48
|
+
/** Validation error. Pass `true` for a generic message, or a string for a custom one. */
|
|
49
|
+
error?: string | boolean;
|
|
50
|
+
/** Icon element rendered on the left side of the input. Takes precedence over `prefix`. */
|
|
51
|
+
iconLeft?: ReactNode;
|
|
52
|
+
/** Icon element rendered on the right side of the input. */
|
|
53
|
+
iconRight?: ReactNode;
|
|
54
|
+
/** Additional CSS classes for the outer container. */
|
|
55
|
+
containerClassName?: string;
|
|
56
|
+
/** Additional CSS classes for the label. */
|
|
57
|
+
labelClassName?: string;
|
|
58
|
+
/** Additional CSS classes for the input element. */
|
|
59
|
+
className?: string;
|
|
60
|
+
/** Auto-focus the input on mount. @default false */
|
|
61
|
+
autoFocus?: boolean;
|
|
62
|
+
/** Select all content on click. @default false */
|
|
63
|
+
focusContent?: boolean;
|
|
64
|
+
/** Keydown handler for the underlying input. */
|
|
65
|
+
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
|
66
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
import { FaDollarSign } from 'react-icons/fa';
|
|
4
|
+
import ITInputNumber from '@/components/atoms/input-number/input-number';
|
|
5
|
+
import { ITInputNumberProps } from './input-number.props';
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof ITInputNumber> = {
|
|
8
|
+
title: 'Components/Form Elements/ITInputNumber',
|
|
9
|
+
component: ITInputNumber,
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: 'centered',
|
|
12
|
+
},
|
|
13
|
+
argTypes: {
|
|
14
|
+
decimals: { control: 'number' },
|
|
15
|
+
disabled: { control: 'boolean' },
|
|
16
|
+
error: { control: 'text' },
|
|
17
|
+
min: { control: 'number' },
|
|
18
|
+
max: { control: 'number' },
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default meta;
|
|
23
|
+
|
|
24
|
+
type Story = StoryObj<typeof ITInputNumber>;
|
|
25
|
+
|
|
26
|
+
/** Wrapper that keeps the value in state so the story reflects real usage. */
|
|
27
|
+
const Interactive = (props: Partial<ITInputNumberProps> & Pick<ITInputNumberProps, 'name'>) => {
|
|
28
|
+
const [value, setValue] = useState<number | null>(null);
|
|
29
|
+
return (
|
|
30
|
+
<div className="w-80">
|
|
31
|
+
<ITInputNumber
|
|
32
|
+
label="Monto"
|
|
33
|
+
placeholder="0.00"
|
|
34
|
+
{...props}
|
|
35
|
+
value={value}
|
|
36
|
+
onChange={(next) => setValue(next ?? null)}
|
|
37
|
+
/>
|
|
38
|
+
<p className="mt-2 text-xs font-mono text-slate-500">
|
|
39
|
+
value: {value === null ? 'null' : `${value} (${typeof value})`}
|
|
40
|
+
</p>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const Default: Story = {
|
|
46
|
+
render: () => <Interactive name="amount" />,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const WithPrefix: Story = {
|
|
50
|
+
render: () => <Interactive name="amount-prefix" prefix="$" />,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const WithIcon: Story = {
|
|
54
|
+
render: () => (
|
|
55
|
+
<Interactive name="amount-icon" iconLeft={<FaDollarSign className="text-slate-400" />} />
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const InitialValue: Story = {
|
|
60
|
+
render: () => {
|
|
61
|
+
const [value, setValue] = useState<number | null>(1234.5);
|
|
62
|
+
return (
|
|
63
|
+
<div className="w-80">
|
|
64
|
+
<ITInputNumber
|
|
65
|
+
name="amount-initial"
|
|
66
|
+
label="Monto inicial"
|
|
67
|
+
prefix="$"
|
|
68
|
+
value={value}
|
|
69
|
+
onChange={(next) => setValue(next ?? null)}
|
|
70
|
+
/>
|
|
71
|
+
<p className="mt-2 text-xs font-mono text-slate-500">value: {value === null ? 'null' : value}</p>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const IntegersOnly: Story = {
|
|
78
|
+
render: () => <Interactive name="quantity" label="Cantidad" decimals={0} />,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const WithBounds: Story = {
|
|
82
|
+
render: () => <Interactive name="bounded" label="Entre 10 y 500" min={10} max={500} />,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const WithError: Story = {
|
|
86
|
+
render: () => (
|
|
87
|
+
<Interactive
|
|
88
|
+
name="amount-error"
|
|
89
|
+
label="Monto"
|
|
90
|
+
prefix="$"
|
|
91
|
+
touched
|
|
92
|
+
error="El monto es obligatorio"
|
|
93
|
+
/>
|
|
94
|
+
),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const Disabled: Story = {
|
|
98
|
+
render: () => (
|
|
99
|
+
<div className="w-80">
|
|
100
|
+
<ITInputNumber name="amount-disabled" label="Monto" value={1234.5} disabled onChange={() => {}} />
|
|
101
|
+
</div>
|
|
102
|
+
),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const ReadOnly: Story = {
|
|
106
|
+
render: () => (
|
|
107
|
+
<div className="w-80">
|
|
108
|
+
<ITInputNumber name="amount-readonly" label="Monto" prefix="$" value={98765.43} readOnly onChange={() => {}} />
|
|
109
|
+
</div>
|
|
110
|
+
),
|
|
111
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ChangeEvent, FocusEvent } from "react";
|
|
2
|
+
import ITInput from "@/components/atoms/input/input";
|
|
3
|
+
import ITText from "@/components/atoms/text/text";
|
|
4
|
+
import { ITInputNumberProps } from "./input-number.props";
|
|
5
|
+
|
|
6
|
+
/** Parse a display value ("1,234.50", 1234.5, "") into a number, or `undefined` when empty. */
|
|
7
|
+
function parseNumber(raw: unknown): number | undefined {
|
|
8
|
+
if (raw === undefined || raw === null) return undefined;
|
|
9
|
+
if (typeof raw === "number") return Number.isFinite(raw) ? raw : undefined;
|
|
10
|
+
const cleaned = String(raw).replace(/,/g, "").trim();
|
|
11
|
+
if (cleaned === "" || cleaned === "-" || cleaned === ".") return undefined;
|
|
12
|
+
const parsed = Number(cleaned);
|
|
13
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Numeric field that always renders currency-style formatting (thousand
|
|
18
|
+
* separators + fixed decimals) and emits a real `number`.
|
|
19
|
+
*
|
|
20
|
+
* Built on top of `ITInput`: it is the same field, but `onChange`/`onBlur`
|
|
21
|
+
* already receive the parsed value, so consumers can do math directly instead
|
|
22
|
+
* of converting strings first.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* <ITInputNumber
|
|
27
|
+
* name="amount"
|
|
28
|
+
* label="Monto"
|
|
29
|
+
* value={amount}
|
|
30
|
+
* prefix="$"
|
|
31
|
+
* onChange={(value) => setAmount(value)}
|
|
32
|
+
* />
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```tsx
|
|
37
|
+
* // Enteros (sin decimales) con límites
|
|
38
|
+
* <ITInputNumber
|
|
39
|
+
* name="qty"
|
|
40
|
+
* label="Cantidad"
|
|
41
|
+
* decimals={0}
|
|
42
|
+
* min={1}
|
|
43
|
+
* max={99}
|
|
44
|
+
* value={qty}
|
|
45
|
+
* onChange={(value) => setQty(value)}
|
|
46
|
+
* />
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export default function ITInputNumber({
|
|
50
|
+
value,
|
|
51
|
+
onChange,
|
|
52
|
+
onBlur,
|
|
53
|
+
decimals = 2,
|
|
54
|
+
prefix,
|
|
55
|
+
iconLeft,
|
|
56
|
+
...rest
|
|
57
|
+
}: ITInputNumberProps) {
|
|
58
|
+
const resolvedIconLeft =
|
|
59
|
+
iconLeft ??
|
|
60
|
+
(prefix ? (
|
|
61
|
+
<ITText as="span" className="text-secondary-500 text-xs font-semibold">
|
|
62
|
+
{prefix}
|
|
63
|
+
</ITText>
|
|
64
|
+
) : undefined);
|
|
65
|
+
|
|
66
|
+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
67
|
+
onChange(parseNumber(event.target.value), event);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<ITInput
|
|
72
|
+
{...rest}
|
|
73
|
+
type="number"
|
|
74
|
+
currencyFormat
|
|
75
|
+
formatNumber
|
|
76
|
+
decimals={decimals}
|
|
77
|
+
iconLeft={resolvedIconLeft}
|
|
78
|
+
value={value ?? ""}
|
|
79
|
+
onChange={handleChange}
|
|
80
|
+
onBlur={(event) => {
|
|
81
|
+
const input = event.target as HTMLInputElement;
|
|
82
|
+
onBlur?.(parseNumber(input.value), event as FocusEvent<HTMLInputElement>);
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -11,7 +11,7 @@ Basic data table with column definitions, sorting, and configurable visual varia
|
|
|
11
11
|
|
|
12
12
|
| Prop | Type | Default | Description |
|
|
13
13
|
|------|------|---------|-------------|
|
|
14
|
-
| `columns` | `Column<T>[]` | — | Column definitions: key, label, type, sortable, filter, render, actions, plus layout fields `width` (`number` px or CSS `string`), `minWidth` (px), `align` (`"left" \| "center" \| "right"`),
|
|
14
|
+
| `columns` | `Column<T>[]` | — | Column definitions: key, label, type, sortable, filter, render, actions, plus layout fields `width` (`number` px or CSS `string`), `minWidth` (px), `align` (`"left" \| "center" \| "right"`), `truncate` (ellipsis + native `title`), and `pinned` (`"left" \| "right"`). |
|
|
15
15
|
| `data` | `T[]` | `[]` | Row data array. |
|
|
16
16
|
| `variant` | `"default" \| "striped" \| "bordered"` | `"default"` | Visual style variant. |
|
|
17
17
|
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Font-size preset (does not change padding). |
|
|
@@ -42,6 +42,7 @@ Basic data table with column definitions, sorting, and configurable visual varia
|
|
|
42
42
|
| `minWidth` | `number` | — | Minimum column width in px, applied to header and body cells via inline style. |
|
|
43
43
|
| `align` | `"left" \| "center" \| "right"` | `"left"` | Horizontal alignment of the column's header and cells. |
|
|
44
44
|
| `truncate` | `boolean` | `false` | Truncates overflowing cell content with an ellipsis and exposes the raw string/number via the native `title` attribute. |
|
|
45
|
+
| `pinned` | `"left" \| "right"` | `undefined` | Freezes the column to the given horizontal edge while the table scrolls sideways. `"left"` keeps it flush to the left edge, `"right"` to the right edge. Omit to leave the column unpinned (identical to the previous behavior). Offsets accumulate from each preceding pinned column's pixel `width` / `minWidth`, so give every pinned column of a side a pixel `width`. The pinned cell gets an opaque background (row background + row hover) and a 1px separator shadow on its frozen edge. One pinned column per side is the supported contract. |
|
|
45
46
|
| `filter` | `true \| "catalog" \| "search" \| "date" \| "date-range"` | `false` | Filter control rendered in the header. `true` = text/number input (or boolean toggle when `type: "boolean"`); `"catalog"` = `ITSelect` fed by `catalogOptions.data`; `"search"` = `ITSearchSelect` typeahead fed by `catalogOptions.data` (optional `catalogOptions.onSearch` for server-side search); `"date"` / `"date-range"` = `ITDatePicker` single/range, bounded by `dateFilterOptions`. |
|
|
46
47
|
|
|
47
48
|
## Usage
|
|
@@ -85,6 +86,15 @@ const fixedColumns = [
|
|
|
85
86
|
];
|
|
86
87
|
|
|
87
88
|
<ITTable columns={fixedColumns} data={data} layout="fixed" density="compact" />
|
|
89
|
+
|
|
90
|
+
// Pinned columns: freeze the first column left and the last one right
|
|
91
|
+
const pinnedColumns = [
|
|
92
|
+
{ key: 'id', label: 'ID', type: 'number', width: 72, pinned: 'left' },
|
|
93
|
+
{ key: 'name', label: 'Name', type: 'string', width: 240, truncate: true },
|
|
94
|
+
{ key: 'actions', label: 'Actions', type: 'actions', width: 104, pinned: 'right' },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
<ITTable columns={pinnedColumns} data={data} layout="fixed" density="compact" />
|
|
88
98
|
```
|
|
89
99
|
|
|
90
100
|
## Notes
|
|
@@ -97,6 +107,7 @@ const fixedColumns = [
|
|
|
97
107
|
- When `onRowClick` is omitted no `cursor-pointer`, focus ring, `tabIndex`, or `role` is added.
|
|
98
108
|
- `density` controls padding/row height (`compact` 33px / `normal` 45px / `comfortable` 53px at `size="md"`); `size` changes font-size and shifts the row height by the line box (sm 16px / md 20px / lg 28px).
|
|
99
109
|
- **Anti horizontal scroll recipe:** `layout="fixed"` + a `width` on every column + `truncate` on long text + `density="compact"`. `width` accepts a `number` (px) or any CSS length string (`"20%"`, `"12rem"`). A `<colgroup>` is only emitted when at least one column defines a `width`.
|
|
110
|
+
- **Pinned columns:** set `columns[].pinned: "left" | "right"` to freeze a column to that edge during horizontal scroll (`position: sticky`; the wrapper needs no `relative`). Omitting `pinned` renders exactly as before. Sticky offsets accumulate from each preceding pinned column's pixel `width` / `minWidth`; a non-px width contributes `minWidth ?? 0`, so give every pinned column of a side a pixel `width`. The pinned cell gets an opaque background (`--color-table-rowBg`, row hover `--color-table-rowHover`) and a 1px separator shadow on its frozen edge. Z-layering: pinned body cell `z-[1]` < sticky header `z-10` < pinned header `z-[15]`; it composes with `stickyHeader` and `virtualized`. One pinned column per side is the supported contract. Known limitation: with `variant="striped"` the opaque pinned background covers that column's stripe tint.
|
|
100
111
|
- With the default `layout="auto"`, `min-w-max` stays active so column widths can still reflow as different cell content enters the window during vertical scroll. Use `layout="fixed"` + column `width` for stable widths.
|
|
101
112
|
- `autoCardBreakpoint` swaps to the cards view when the container (not the viewport) is narrower than the given px value; the Table toggle is disabled while forced, the Cards toggle stays enabled.
|
|
102
113
|
- **Virtualization** applies to the **table view only** — the cards view is never virtualized. It only pays off with a large page size: raise `defaultItemsPerPage` and `itemsPerPageOptions` (defaults are `10` and `[5, 10, 20]`). Rows are assumed to have a uniform `rowHeight`; use `truncate` + `layout="fixed"` for predictable heights.
|
|
@@ -90,6 +90,23 @@ export interface Column<T = any> {
|
|
|
90
90
|
* @default false
|
|
91
91
|
*/
|
|
92
92
|
truncate?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Pins this column to a horizontal edge so it stays visible while the table
|
|
95
|
+
* scrolls sideways.
|
|
96
|
+
*
|
|
97
|
+
* - `"left"` — freezes the column flush to the left edge.
|
|
98
|
+
* - `"right"` — freezes the column flush to the right edge.
|
|
99
|
+
*
|
|
100
|
+
* Omit to leave the column unpinned. When more than one column shares a side,
|
|
101
|
+
* sticky offsets accumulate from each preceding pinned column's `width` /
|
|
102
|
+
* `minWidth`; a column without a pixel width resolves to `0px`, so declare a
|
|
103
|
+
* pixel `width` on every pinned column of that side to avoid overlap. The
|
|
104
|
+
* pinned cell renders an opaque background (row background + row hover) and a
|
|
105
|
+
* 1px separator shadow on its frozen edge.
|
|
106
|
+
*
|
|
107
|
+
* @default undefined (column not pinned)
|
|
108
|
+
*/
|
|
109
|
+
pinned?: "left" | "right";
|
|
93
110
|
}
|
|
94
111
|
|
|
95
112
|
export interface ITTableProps<T> {
|
|
@@ -272,6 +272,51 @@ export const FixedLayoutWithWidths: Story = {
|
|
|
272
272
|
} as any,
|
|
273
273
|
};
|
|
274
274
|
|
|
275
|
+
// Pixel widths force the total past the 520px wrapper, so the ID (left) and
|
|
276
|
+
// Actions (right) columns stay frozen while the middle scrolls sideways.
|
|
277
|
+
const pinnedColumns: Column<User>[] = [
|
|
278
|
+
{ key: "id", label: "ID", type: "number", width: 72, minWidth: 56, align: "center", pinned: "left" },
|
|
279
|
+
{ key: "name", label: "Name", type: "string", width: 240, truncate: true },
|
|
280
|
+
{ key: "email", label: "Email", type: "string", width: 280, truncate: true },
|
|
281
|
+
{ key: "balance", label: "Balance", type: "number", width: 160, currencyMX: true },
|
|
282
|
+
{ key: "lastLogin", label: "Last login", type: "string", width: 160, truncate: true },
|
|
283
|
+
{
|
|
284
|
+
key: "isActive",
|
|
285
|
+
label: "Status",
|
|
286
|
+
type: "boolean",
|
|
287
|
+
width: 104,
|
|
288
|
+
align: "center",
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
key: "actions",
|
|
292
|
+
label: "Actions",
|
|
293
|
+
type: "actions",
|
|
294
|
+
width: 104,
|
|
295
|
+
pinned: "right",
|
|
296
|
+
actions: () => (
|
|
297
|
+
<ITButton size="sm" variant="text" color="danger" ariaLabel="Delete">
|
|
298
|
+
<FaTrash />
|
|
299
|
+
</ITButton>
|
|
300
|
+
),
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
export const PinnedColumns: Story = {
|
|
305
|
+
render: (args: any) => (
|
|
306
|
+
<div style={{ width: 520 }} className="rounded-lg border border-dashed border-slate-300">
|
|
307
|
+
<ITTable {...args} />
|
|
308
|
+
</div>
|
|
309
|
+
),
|
|
310
|
+
args: {
|
|
311
|
+
columns: pinnedColumns,
|
|
312
|
+
data: mockData.slice(0, 6),
|
|
313
|
+
title: "Pinned left (ID) & right (Actions)",
|
|
314
|
+
layout: "fixed",
|
|
315
|
+
density: "compact",
|
|
316
|
+
defaultItemsPerPage: 6,
|
|
317
|
+
} as any,
|
|
318
|
+
};
|
|
319
|
+
|
|
275
320
|
export const AutoCardsNarrowContainer: Story = {
|
|
276
321
|
render: (args: any) => (
|
|
277
322
|
<div style={{ width: 420 }} className="rounded-lg border border-dashed border-slate-300">
|