@axzydev/axzy_ui_system 1.3.5 → 1.4.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 (34) hide show
  1. package/dist/index.cjs +562 -506
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.cts +107 -3
  5. package/dist/index.d.ts +107 -3
  6. package/dist/index.js +433 -378
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/App.tsx +6 -0
  10. package/src/components/atoms/input/input.props.ts +5 -0
  11. package/src/components/atoms/input/input.tsx +24 -17
  12. package/src/components/atoms/input-number/input-number.doc.mdx +74 -0
  13. package/src/components/atoms/input-number/input-number.props.ts +66 -0
  14. package/src/components/atoms/input-number/input-number.stories.tsx +111 -0
  15. package/src/components/atoms/input-number/input-number.tsx +86 -0
  16. package/src/components/molecules/date-picker/datePicker.tsx +1 -0
  17. package/src/components/molecules/dropdown-menu/dropdown-menu.tsx +1 -0
  18. package/src/components/molecules/multi-select/multi-select.tsx +1 -0
  19. package/src/components/molecules/search-select/search-select.tsx +1 -0
  20. package/src/components/molecules/time-picker/timePicker.tsx +1 -0
  21. package/src/components/organisms/dialog/dialog.stories.tsx +48 -0
  22. package/src/components/organisms/sidebar/sidebar.doc.mdx +12 -12
  23. package/src/hooks/useClickOutside.ts +6 -0
  24. package/src/hooks/useFloatingPanel.ts +4 -1
  25. package/src/index.ts +4 -2
  26. package/src/sandbox/navigation.ts +2 -0
  27. package/src/showcases/FeedbackShowcases.tsx +16 -1
  28. package/src/showcases/FormShowcases.tsx +287 -1
  29. package/src/showcases/HomeShowcase.tsx +552 -331
  30. package/src/showcases/ProductoDetalle.tsx +181 -0
  31. package/src/showcases/ProductoIcono.tsx +25 -0
  32. package/src/showcases/StructureShowcases.tsx +2 -2
  33. package/src/showcases/productos.ts +179 -0
  34. package/src/showcases/techIcons.ts +67 -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.5",
7
+ "version": "1.4.0",
8
8
  "type": "module",
9
9
  "sideEffects": [
10
10
  "*.css"
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: 2,
150
- maximumFractionDigits: 2,
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 (!currencyFormat && (key === "." || key === ",")) {
214
+ if (decimalPlaces === 0 && (key === "." || key === ",")) {
208
215
  e.preventDefault();
209
216
  return;
210
217
  }
211
218
 
212
219
  if (
213
- currencyFormat &&
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 = currencyFormat ? /^[0-9.,]$/ : /^[0-9]$/;
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 (currencyFormat) {
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, and restrict decimals to 2 digits
270
- const decimals = parts.slice(1).join("").substring(0, 2);
271
- cleanedValue = parts[0] + "." + decimals;
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 (currencyFormat) {
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 (currencyFormat) {
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 = currencyFormat
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
- ? (currencyFormat ? displayValue.replace(/[.,]/g, "") : displayValue).length
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
- ? currencyFormat
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 && !currencyFormat ? maxLength : undefined}
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
+ }
@@ -292,6 +292,7 @@ export default function ITDatePicker({
292
292
  createPortal(
293
293
  <div
294
294
  ref={panelRef}
295
+ data-it-floating="true"
295
296
  className={clsx(
296
297
  calendarClassName,
297
298
  range ? "w-[320px]" : "w-[280px]"
@@ -172,6 +172,7 @@ export default function ITDropdownMenu({
172
172
  createPortal(
173
173
  <div
174
174
  ref={panelRef}
175
+ data-it-floating="true"
175
176
  role="menu"
176
177
  aria-label={triggerLabel}
177
178
  style={panelStyle}
@@ -323,6 +323,7 @@ export default function ITMultiSelect({
323
323
  createPortal(
324
324
  <div
325
325
  ref={panelRef}
326
+ data-it-floating="true"
326
327
  role="listbox"
327
328
  aria-multiselectable="true"
328
329
  style={panelStyle}
@@ -283,6 +283,7 @@ export default function ITSearchSelect({
283
283
  createPortal(
284
284
  <div
285
285
  ref={panelRef}
286
+ data-it-floating="true"
286
287
  style={panelStyle}
287
288
  className="bg-white dark:bg-slate-900 border border-secondary-200 dark:border-slate-800 rounded-lg shadow-xl overflow-hidden animate-in fade-in zoom-in duration-200 origin-top"
288
289
  >
@@ -217,6 +217,7 @@ export default function ITTimePicker({
217
217
  {isOpen && !disabled && createPortal(
218
218
  <div
219
219
  ref={dropdownRef}
220
+ data-it-floating="true"
220
221
  className="bg-white border border-secondary-100 shadow-xl rounded-xl w-64 overflow-hidden flex flex-col animate-in fade-in zoom-in-95 duration-200 origin-top it-timepicker-dropdown"
221
222
  style={panelStyle}
222
223
  >
@@ -1,6 +1,8 @@
1
1
  import type { Meta } from '@storybook/react';
2
2
  import ITDialog from '@/components/organisms/dialog/dialog';
3
+ import ITSearchSelect from '@/components/molecules/search-select/search-select';
3
4
  import { useState } from 'react';
5
+ import { expect, userEvent, within } from 'storybook/test';
4
6
  import ITButton from '@/components/atoms/button/button';
5
7
 
6
8
  const meta = {
@@ -76,3 +78,49 @@ export const LongContent: any = {
76
78
  className: 'w-[600px]',
77
79
  } as any,
78
80
  };
81
+
82
+ // Dialog opened on mount but controlled, so an unexpected close is observable.
83
+ const DialogWithSearchSelect = () => {
84
+ const [fruit, setFruit] = useState('');
85
+ const [isOpen, setIsOpen] = useState(true);
86
+
87
+ return (
88
+ <ITDialog isOpen={isOpen} onClose={() => setIsOpen(false)} title="Fruta favorita">
89
+ <ITSearchSelect
90
+ label="Fruta"
91
+ placeholder="Busca una fruta..."
92
+ options={[
93
+ { value: 'apple', label: 'Apple' },
94
+ { value: 'banana', label: 'Banana' },
95
+ ]}
96
+ value={fruit}
97
+ onChange={(next) => setFruit(String(next))}
98
+ />
99
+ </ITDialog>
100
+ );
101
+ };
102
+
103
+ /**
104
+ * Regression test: floating panels (selects, pickers, menus) are portaled to
105
+ * `<body>`, so they live outside the dialog DOM. Picking an option there must
106
+ * not count as a click outside the dialog.
107
+ *
108
+ * Note the whole dialog and its dropdown are portaled, so they are queried
109
+ * from `document.body` instead of the story canvas.
110
+ */
111
+ export const FloatingPanelInsideDialog: any = {
112
+ render: () => <DialogWithSearchSelect />,
113
+ play: async () => {
114
+ const view = within(document.body);
115
+
116
+ const input = await view.findByPlaceholderText('Busca una fruta...');
117
+ await userEvent.click(input);
118
+
119
+ await userEvent.click(await view.findByText('Banana'));
120
+
121
+ // The dialog must still be mounted after picking an option.
122
+ await expect(view.getByText('Fruta favorita')).toBeVisible();
123
+ await expect((input as HTMLInputElement).value).toBe('Banana');
124
+ },
125
+ };
126
+