@axzydev/axzy_ui_system 1.2.15 → 1.3.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 (60) hide show
  1. package/dist/index.cjs +3067 -1409
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.cts +620 -54
  5. package/dist/index.d.ts +620 -54
  6. package/dist/index.js +2983 -1320
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/App.tsx +28 -0
  10. package/src/components/atoms/badget/badget.tsx +2 -4
  11. package/src/components/atoms/chip/chip.doc.mdx +47 -0
  12. package/src/components/atoms/chip/chip.props.ts +32 -0
  13. package/src/components/atoms/chip/chip.stories.tsx +64 -0
  14. package/src/components/atoms/chip/chip.tsx +112 -0
  15. package/src/components/atoms/grid/grid.tsx +1 -2
  16. package/src/components/molecules/accordion/accordion.doc.mdx +47 -0
  17. package/src/components/molecules/accordion/accordion.props.ts +35 -0
  18. package/src/components/molecules/accordion/accordion.stories.tsx +68 -0
  19. package/src/components/molecules/accordion/accordion.tsx +123 -0
  20. package/src/components/molecules/chip-input/chip-input.doc.mdx +44 -0
  21. package/src/components/molecules/chip-input/chip-input.props.ts +42 -0
  22. package/src/components/molecules/chip-input/chip-input.stories.tsx +52 -0
  23. package/src/components/molecules/chip-input/chip-input.tsx +205 -0
  24. package/src/components/molecules/date-picker/datePicker.tsx +45 -56
  25. package/src/components/molecules/dropdown-menu/dropdown-menu.doc.mdx +46 -0
  26. package/src/components/molecules/dropdown-menu/dropdown-menu.props.ts +41 -0
  27. package/src/components/molecules/dropdown-menu/dropdown-menu.stories.tsx +41 -0
  28. package/src/components/molecules/dropdown-menu/dropdown-menu.tsx +219 -0
  29. package/src/components/molecules/field/field.doc.mdx +43 -0
  30. package/src/components/molecules/field/field.props.ts +23 -0
  31. package/src/components/molecules/field/field.stories.tsx +42 -0
  32. package/src/components/molecules/field/field.tsx +54 -0
  33. package/src/components/molecules/multi-select/multi-select.doc.mdx +55 -0
  34. package/src/components/molecules/multi-select/multi-select.props.ts +65 -0
  35. package/src/components/molecules/multi-select/multi-select.stories.tsx +59 -0
  36. package/src/components/molecules/multi-select/multi-select.tsx +386 -0
  37. package/src/components/molecules/search-select/search-select.doc.mdx +5 -0
  38. package/src/components/molecules/search-select/search-select.props.ts +10 -1
  39. package/src/components/molecules/search-select/search-select.stories.tsx +18 -0
  40. package/src/components/molecules/search-select/search-select.tsx +102 -28
  41. package/src/components/molecules/time-picker/timePicker.tsx +14 -26
  42. package/src/components/molecules/wysiwyg/wysiwyg.props.ts +51 -0
  43. package/src/components/molecules/wysiwyg/wysiwyg.stories.tsx +95 -0
  44. package/src/components/molecules/wysiwyg/wysiwyg.tsx +609 -0
  45. package/src/components/organisms/data-table/dataTable.tsx +16 -5
  46. package/src/components/organisms/form-builder/fieldRenderer.tsx +144 -27
  47. package/src/components/organisms/form-builder/formBuilder.context.tsx +77 -15
  48. package/src/components/organisms/form-builder/formBuilder.doc.mdx +213 -86
  49. package/src/components/organisms/form-builder/formBuilder.props.ts +118 -36
  50. package/src/components/organisms/form-builder/formBuilder.stories.tsx +694 -96
  51. package/src/components/organisms/form-builder/formBuilder.tsx +152 -69
  52. package/src/components/organisms/form-builder/useFormBuilder.ts +59 -21
  53. package/src/hooks/useFloatingPanel.ts +100 -0
  54. package/src/index.ts +24 -4
  55. package/src/showcases/DataShowcases.tsx +76 -0
  56. package/src/showcases/FormShowcases.tsx +396 -10
  57. package/src/showcases/HomeShowcase.tsx +6 -6
  58. package/src/showcases/NavigationShowcases.tsx +72 -2
  59. package/src/showcases/StructureShowcases.tsx +64 -0
  60. package/src/types/chip.types.ts +13 -0
@@ -0,0 +1,123 @@
1
+ import clsx from "clsx";
2
+ import { useState } from "react";
3
+ import { FaChevronDown } from "react-icons/fa";
4
+ import { ITAccordionProps } from "./accordion.props";
5
+ import ITText from "@/components/atoms/text/text";
6
+
7
+ /**
8
+ * Collapsible sections for FAQs, settings panels, and long content.
9
+ *
10
+ * Works uncontrolled (`defaultOpenIds`) or controlled (`openIds` + `onChange`).
11
+ * Supports single-open (default) or multiple-open mode, and animates the body
12
+ * with a CSS grid-rows transition (no JS height measurement).
13
+ *
14
+ * @example
15
+ * <ITAccordion
16
+ * items={[
17
+ * { id: "a", title: "¿Qué es AXZY?", content: "Un sistema de componentes." },
18
+ * { id: "b", title: "¿Cómo instalo?", content: "pnpm add @axzydev/axzy_ui_system" },
19
+ * ]}
20
+ * defaultOpenIds={["a"]}
21
+ * />
22
+ */
23
+ export default function ITAccordion({
24
+ items,
25
+ allowMultiple = false,
26
+ defaultOpenIds = [],
27
+ openIds,
28
+ onChange,
29
+ variant = "separated",
30
+ className,
31
+ itemClassName,
32
+ }: ITAccordionProps) {
33
+ const [internalOpen, setInternalOpen] = useState<string[]>(defaultOpenIds);
34
+ const isControlled = openIds !== undefined;
35
+ const currentOpen = isControlled ? openIds : internalOpen;
36
+
37
+ const toggle = (id: string) => {
38
+ const isOpen = currentOpen.includes(id);
39
+ let next: string[];
40
+ if (isOpen) {
41
+ next = currentOpen.filter((x) => x !== id);
42
+ } else {
43
+ next = allowMultiple ? [...currentOpen, id] : [id];
44
+ }
45
+ if (!isControlled) setInternalOpen(next);
46
+ onChange?.(next);
47
+ };
48
+
49
+ const wrapperClass =
50
+ variant === "separated"
51
+ ? clsx("flex flex-col gap-2", className)
52
+ : clsx(
53
+ "rounded-xl border border-secondary-200 dark:border-slate-700 overflow-hidden",
54
+ className
55
+ );
56
+
57
+ return (
58
+ <div className={wrapperClass}>
59
+ {items.map((item, index) => {
60
+ const isOpen = currentOpen.includes(item.id);
61
+ const panelId = `it-accordion-panel-${item.id}`;
62
+ const buttonId = `it-accordion-button-${item.id}`;
63
+ const itemClass = clsx(
64
+ variant === "separated" &&
65
+ "rounded-xl border border-secondary-200 dark:border-slate-700 overflow-hidden bg-white dark:bg-slate-900",
66
+ variant === "bordered" &&
67
+ index > 0 &&
68
+ "border-t border-secondary-200 dark:border-slate-700",
69
+ item.disabled && "opacity-50",
70
+ itemClassName
71
+ );
72
+
73
+ return (
74
+ <div key={item.id} className={itemClass}>
75
+ <h3 className="m-0">
76
+ <button
77
+ type="button"
78
+ id={buttonId}
79
+ aria-expanded={isOpen}
80
+ aria-controls={panelId}
81
+ disabled={item.disabled}
82
+ onClick={() => toggle(item.id)}
83
+ className={clsx(
84
+ "w-full flex items-center gap-3 px-4 py-3 text-left transition-colors",
85
+ "text-sm font-semibold text-secondary-800 dark:text-slate-100",
86
+ "hover:bg-secondary-50 dark:hover:bg-slate-800",
87
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-400",
88
+ item.disabled && "cursor-not-allowed hover:bg-transparent"
89
+ )}
90
+ >
91
+ {item.icon && <span className="shrink-0 text-secondary-500">{item.icon}</span>}
92
+ <ITText as="span" className="flex-1">{item.title}</ITText>
93
+ <FaChevronDown
94
+ size={12}
95
+ className={clsx(
96
+ "shrink-0 text-secondary-400 transition-transform duration-200",
97
+ isOpen && "rotate-180"
98
+ )}
99
+ />
100
+ </button>
101
+ </h3>
102
+
103
+ <div
104
+ id={panelId}
105
+ role="region"
106
+ aria-labelledby={buttonId}
107
+ className={clsx(
108
+ "grid transition-all duration-200 ease-in-out",
109
+ isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
110
+ )}
111
+ >
112
+ <div className="overflow-hidden">
113
+ <div className="px-4 pb-4 pt-1 text-sm text-secondary-600 dark:text-slate-300">
114
+ {item.content}
115
+ </div>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ );
120
+ })}
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,44 @@
1
+ import { Meta, Title, Description, Primary, Controls, Stories } from '@storybook/blocks';
2
+ import * as StoriesData from './chip-input.stories';
3
+
4
+ <Meta of={StoriesData} />
5
+
6
+ # ITChipInput
7
+
8
+ Free-text input that turns entered values into removable tags (chips).
9
+
10
+ ## Props
11
+
12
+ | Prop | Type | Default | Description |
13
+ |------|------|---------|-------------|
14
+ | `value` | `string[]` | — | Current tags (controlled). |
15
+ | `onChange` | `(values: string[]) => void` | — | Fired with the next list of tags. |
16
+ | `label` | `string` | — | Label above the control. |
17
+ | `placeholder` | `string` | `"Escribe y presiona Enter"` | Input placeholder. |
18
+ | `delimiters` | `string[]` | `["Enter", ","]` | Keys that commit a tag. |
19
+ | `maxTags` | `number` | — | Maximum number of tags. |
20
+ | `allowDuplicates` | `boolean` | `false` | Allows repeated tags. |
21
+ | `validate` | `(value) => string \| undefined` | — | Rejects a tag with a message. |
22
+ | `color` | semantic color key | `"primary"` | Color of the tag chips. |
23
+ | `size` | `"sm" \| "md" \| "lg"` | `"md"` | Control size. |
24
+ | `disabled` / `required` / `touched` / `error` / `helpText` | — | — | Standard form states. |
25
+
26
+ ## Usage
27
+
28
+ ```tsx
29
+ import { ITChipInput } from '@axzydev/axzy_ui_system';
30
+
31
+ <ITChipInput label="Etiquetas" value={tags} onChange={setTags} />
32
+ ```
33
+
34
+ ## Notes
35
+
36
+ - Commits tags on `Enter`, `,`, blur, or when pasting delimited text.
37
+ - `Backspace` on an empty input removes the last tag.
38
+ - Local errors (duplicate, max reached, `validate`) are shown inline.
39
+
40
+ ## Stories
41
+
42
+ <Primary />
43
+ <Controls />
44
+ <Stories />
@@ -0,0 +1,42 @@
1
+ import { ColorsTypes } from "@/types/colors.types";
2
+ import { SizesTypes } from "@/types/sizes.types";
3
+
4
+ /** Props for the ITChipInput component. */
5
+ export interface ITChipInputProps {
6
+ /** Name attribute for form integrations. */
7
+ name?: string;
8
+ /** Label displayed above the control. */
9
+ label?: string;
10
+ /** Placeholder for the text input. @default "Escribe y presiona Enter" */
11
+ placeholder?: string;
12
+ /** Current list of tags (controlled). */
13
+ value: string[];
14
+ /** Fired with the next list of tags. */
15
+ onChange?: (values: string[]) => void;
16
+ /** Fired when the control loses focus. */
17
+ onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
18
+ /** Keys that commit the current text as a tag. @default ["Enter", ","] */
19
+ delimiters?: string[];
20
+ /** Maximum number of tags allowed. */
21
+ maxTags?: number;
22
+ /** Allows duplicated tags. @default false */
23
+ allowDuplicates?: boolean;
24
+ /** Returns an error string to reject the tag, or undefined if valid. */
25
+ validate?: (value: string) => string | undefined;
26
+ /** Color applied to the tag chips. @default "primary" */
27
+ color?: ColorsTypes;
28
+ /** Control size. Valid values: `"sm"`, `"md"`, `"lg"`. @default "md" */
29
+ size?: SizesTypes;
30
+ /** Disables the control. @default false */
31
+ disabled?: boolean;
32
+ /** Marks the field as required (red asterisk). @default false */
33
+ required?: boolean;
34
+ /** Whether the field has been touched (form validation). */
35
+ touched?: boolean;
36
+ /** Error message or boolean indicating an error state. */
37
+ error?: string | boolean;
38
+ /** Helper text shown below the control when there is no error. */
39
+ helpText?: string;
40
+ /** Additional CSS classes for the container. */
41
+ className?: string;
42
+ }
@@ -0,0 +1,52 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import ITChipInput from "@/components/molecules/chip-input/chip-input";
4
+
5
+ const meta: Meta<typeof ITChipInput> = {
6
+ title: "Components/Form Elements/ITChipInput",
7
+ component: ITChipInput,
8
+ parameters: { layout: "centered" },
9
+ };
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof ITChipInput>;
13
+
14
+ const Wrapper = (args: any) => {
15
+ const [value, setValue] = useState<string[]>(args.value ?? []);
16
+ return (
17
+ <div className="w-[420px]">
18
+ <ITChipInput {...args} value={value} onChange={setValue} />
19
+ </div>
20
+ );
21
+ };
22
+
23
+ export const Default: Story = {
24
+ render: (args) => <Wrapper {...args} />,
25
+ args: { name: "tags", label: "Etiquetas", value: [] },
26
+ };
27
+
28
+ export const WithValues: Story = {
29
+ render: (args) => <Wrapper {...args} />,
30
+ args: { name: "tags", label: "Etiquetas", value: ["react", "typescript", "tailwind"] },
31
+ };
32
+
33
+ export const MaxTags: Story = {
34
+ render: (args) => <Wrapper {...args} />,
35
+ args: { name: "tags", label: "Máximo 3", value: ["uno", "dos"], maxTags: 3, helpText: "Máximo 3 etiquetas." },
36
+ };
37
+
38
+ export const WithValidation: Story = {
39
+ render: (args) => <Wrapper {...args} />,
40
+ args: {
41
+ name: "emails",
42
+ label: "Correos",
43
+ value: [],
44
+ placeholder: "correo@dominio.com",
45
+ validate: (v: string) => (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? undefined : "Correo inválido"),
46
+ },
47
+ };
48
+
49
+ export const WithError: Story = {
50
+ render: (args) => <Wrapper {...args} />,
51
+ args: { name: "tags", label: "Etiquetas", value: [], required: true, touched: true, error: true },
52
+ };
@@ -0,0 +1,205 @@
1
+ import clsx from "clsx";
2
+ import { useRef, useState } from "react";
3
+ import { ITChipInputProps } from "./chip-input.props";
4
+ import { theme } from "@/theme/theme";
5
+ import { inputFieldStyle } from "@/utils/styles";
6
+ import ITChip from "@/components/atoms/chip/chip";
7
+ import ITText from "@/components/atoms/text/text";
8
+
9
+ /**
10
+ * Free-text input that turns entered values into removable tags (chips).
11
+ *
12
+ * Commits a tag on `Enter`/`,` (configurable via `delimiters`), on blur, or on
13
+ * paste of delimited text. Supports `maxTags`, dedupe control, and per-tag
14
+ * validation.
15
+ *
16
+ * @example
17
+ * <ITChipInput
18
+ * label="Etiquetas"
19
+ * value={tags}
20
+ * onChange={setTags}
21
+ * placeholder="Agrega una etiqueta..."
22
+ * />
23
+ */
24
+ export default function ITChipInput({
25
+ name,
26
+ label,
27
+ placeholder = "Escribe y presiona Enter",
28
+ value,
29
+ onChange,
30
+ onBlur,
31
+ delimiters = ["Enter", ","],
32
+ maxTags,
33
+ allowDuplicates = false,
34
+ validate,
35
+ color = "primary",
36
+ size = "md",
37
+ disabled = false,
38
+ required = false,
39
+ touched,
40
+ error,
41
+ helpText,
42
+ className,
43
+ }: ITChipInputProps) {
44
+ const [inputValue, setInputValue] = useState("");
45
+ const [localTouched, setLocalTouched] = useState(false);
46
+ const [localError, setLocalError] = useState<string | undefined>(undefined);
47
+ const [isFocused, setIsFocused] = useState(false);
48
+ const inputRef = useRef<HTMLInputElement>(null);
49
+
50
+ const inputTheme = (theme as any).input || {};
51
+
52
+ const isTouched = touched !== undefined ? touched : localTouched;
53
+ const externalError =
54
+ error !== undefined && error !== false
55
+ ? error === true
56
+ ? "Este campo es requerido"
57
+ : error
58
+ : required && value.length === 0
59
+ ? "Este campo es requerido"
60
+ : undefined;
61
+ const effectiveError = localError || externalError;
62
+ const hasError = isTouched && !!effectiveError;
63
+
64
+ const getControlStyle = (): React.CSSProperties => {
65
+ const style = inputFieldStyle(inputTheme, size, { width: "100%", cursor: "text" });
66
+ style.display = "flex";
67
+ style.flexWrap = "wrap";
68
+ style.alignItems = "center";
69
+ style.gap = "0.375rem";
70
+ style.minHeight = "2.5rem";
71
+
72
+ if (disabled) {
73
+ style.backgroundColor = inputTheme.disabled?.backgroundColor || style.backgroundColor;
74
+ style.borderColor = inputTheme.disabled?.borderColor || style.borderColor;
75
+ style.opacity = 0.7;
76
+ style.cursor = "not-allowed";
77
+ }
78
+ if (hasError) {
79
+ style.borderColor = inputTheme.error?.borderColor || "red";
80
+ } else if (isFocused) {
81
+ style.boxShadow = inputTheme.focus?.ring;
82
+ style.borderColor = inputTheme.focus?.borderColor;
83
+ }
84
+ return style;
85
+ };
86
+
87
+ const commitTag = (raw: string) => {
88
+ const tag = raw.trim();
89
+ if (!tag) return;
90
+ if (maxTags !== undefined && value.length >= maxTags) {
91
+ setLocalError(`Máximo ${maxTags} etiquetas`);
92
+ return;
93
+ }
94
+ if (!allowDuplicates && value.includes(tag)) {
95
+ setLocalError("Etiqueta duplicada");
96
+ return;
97
+ }
98
+ const validationError = validate?.(tag);
99
+ if (validationError) {
100
+ setLocalError(validationError);
101
+ return;
102
+ }
103
+ setLocalError(undefined);
104
+ onChange?.([...value, tag]);
105
+ setInputValue("");
106
+ };
107
+
108
+ const removeTag = (tag: string) => {
109
+ if (disabled) return;
110
+ onChange?.(value.filter((t) => t !== tag));
111
+ };
112
+
113
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
114
+ if (disabled) return;
115
+ if (delimiters.includes(e.key)) {
116
+ if (e.key !== "Enter") e.preventDefault();
117
+ commitTag(inputValue);
118
+ } else if (e.key === "Backspace" && inputValue === "" && value.length > 0) {
119
+ removeTag(value[value.length - 1]);
120
+ } else if (localError) {
121
+ setLocalError(undefined);
122
+ }
123
+ };
124
+
125
+ const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
126
+ const text = e.clipboardData.getData("text");
127
+ const separators = delimiters.filter((d) => d.length === 1);
128
+ if (separators.length && separators.some((s) => text.includes(s))) {
129
+ e.preventDefault();
130
+ const parts = text
131
+ .split(new RegExp(`[${separators.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("")}]`))
132
+ .map((p) => p.trim())
133
+ .filter(Boolean);
134
+ parts.forEach((p) => commitTag(p));
135
+ }
136
+ };
137
+
138
+ const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
139
+ if (inputValue.trim()) commitTag(inputValue);
140
+ setIsFocused(false);
141
+ setLocalTouched(true);
142
+ onBlur?.(e);
143
+ };
144
+
145
+ const handleContainerClick = () => {
146
+ if (disabled) return;
147
+ inputRef.current?.focus();
148
+ };
149
+
150
+ return (
151
+ <div className={clsx("w-full flex flex-col gap-1.5", className)}>
152
+ {label && (
153
+ <ITText
154
+ as="label"
155
+ className={clsx("text-sm font-medium text-secondary-700 dark:text-slate-300", {
156
+ "text-danger-500": hasError,
157
+ })}
158
+ >
159
+ <ITText as="span">{label}</ITText>
160
+ {required && <ITText as="span" className="text-danger-500 ml-1">*</ITText>}
161
+ </ITText>
162
+ )}
163
+
164
+ <div
165
+ onClick={handleContainerClick}
166
+ className={clsx(disabled && "cursor-not-allowed")}
167
+ style={getControlStyle()}
168
+ >
169
+ {value.map((tag) => (
170
+ <ITChip
171
+ key={tag}
172
+ label={tag}
173
+ color={color}
174
+ size="sm"
175
+ removable={!disabled}
176
+ onRemove={() => removeTag(tag)}
177
+ />
178
+ ))}
179
+
180
+ <input
181
+ ref={inputRef}
182
+ name={name}
183
+ type="text"
184
+ value={inputValue}
185
+ onChange={(e) => setInputValue(e.target.value)}
186
+ onKeyDown={handleKeyDown}
187
+ onPaste={handlePaste}
188
+ onFocus={() => setIsFocused(true)}
189
+ onBlur={handleBlur}
190
+ disabled={disabled}
191
+ placeholder={value.length === 0 ? placeholder : ""}
192
+ autoComplete="off"
193
+ className="flex-1 min-w-[100px] bg-transparent outline-none border-none text-sm text-secondary-900 dark:text-slate-100"
194
+ style={{ padding: 0, fontSize: inputFieldStyle(inputTheme, size).fontSize }}
195
+ />
196
+ </div>
197
+
198
+ {hasError ? (
199
+ <ITText as="p" className="text-danger-500 text-xs mt-1">{effectiveError}</ITText>
200
+ ) : helpText ? (
201
+ <ITText as="p" className="text-xs text-secondary-500 dark:text-secondary-400">{helpText}</ITText>
202
+ ) : null}
203
+ </div>
204
+ );
205
+ }
@@ -1,10 +1,12 @@
1
1
  import clsx from "clsx";
2
2
  import React, { useEffect, useRef, useState } from "react";
3
+ import { createPortal } from "react-dom";
3
4
  import { FaCalendarAlt } from "react-icons/fa";
4
5
  import { isBefore } from "date-fns";
5
6
  import ITCalendar from "@/components/molecules/calendar/calendar";
6
7
  import ITInput from "@/components/atoms/input/input";
7
8
  import { ITDatePickerProps } from "./date-picker.props";
9
+ import { useFloatingPanel } from "@/hooks/useFloatingPanel";
8
10
  import { theme } from "@/theme/theme";
9
11
 
10
12
  /**
@@ -64,7 +66,12 @@ export default function ITDatePicker({
64
66
  const [internalRange, setInternalRange] = useState<[Date | null, Date | null]>([null, null]);
65
67
 
66
68
  const wrapperRef = useRef<HTMLDivElement>(null);
67
- const [calendarPosition, setCalendarPosition] = useState({ top: 0, left: 0 });
69
+
70
+ // Calendar position (rendered in a portal so it escapes overflow/transform ancestors).
71
+ const { panelRef, style: panelStyle } = useFloatingPanel(wrapperRef, isOpen, {
72
+ estimatedHeight: 360,
73
+ matchWidth: false,
74
+ });
68
75
 
69
76
  // Normalize single vs range values
70
77
  const dateRange = React.useMemo(() => {
@@ -97,10 +104,10 @@ export default function ITDatePicker({
97
104
 
98
105
  useEffect(() => {
99
106
  const handleClickOutside = (event: MouseEvent) => {
100
- if (
101
- wrapperRef.current &&
102
- !wrapperRef.current.contains(event.target as Node)
103
- ) {
107
+ const target = event.target as Node;
108
+ const inWrapper = wrapperRef.current?.contains(target);
109
+ const inPanel = panelRef.current?.contains(target);
110
+ if (!inWrapper && !inPanel) {
104
111
  setIsOpen(false);
105
112
  }
106
113
  };
@@ -108,24 +115,6 @@ export default function ITDatePicker({
108
115
  return () => document.removeEventListener("mousedown", handleClickOutside);
109
116
  }, []);
110
117
 
111
- const calculateCalendarPosition = () => {
112
- if (wrapperRef.current) {
113
- const inputRect = wrapperRef.current.getBoundingClientRect();
114
- const calendarHeight = 300;
115
- const viewportHeight = window.innerHeight;
116
-
117
- let top = inputRect.bottom + 4;
118
- if (inputRect.bottom + calendarHeight > viewportHeight) {
119
- top = inputRect.top - calendarHeight - 4;
120
- }
121
-
122
- setCalendarPosition({
123
- top,
124
- left: inputRect.left,
125
- });
126
- }
127
- };
128
-
129
118
  const handleDateChange = (date: Date) => {
130
119
  if (range) {
131
120
  let newRange: [Date | null, Date | null];
@@ -177,7 +166,6 @@ export default function ITDatePicker({
177
166
 
178
167
  const handleIconClick = () => {
179
168
  if (!disabled) {
180
- calculateCalendarPosition();
181
169
  setIsOpen(!isOpen);
182
170
  }
183
171
  };
@@ -300,38 +288,39 @@ export default function ITDatePicker({
300
288
  onClick={handleIconClick}
301
289
  />
302
290
 
303
- {isOpen && (
304
- <div
305
- className={clsx(
306
- "fixed z-[70]",
307
- calendarClassName,
308
- range ? "w-[320px]" : "w-[280px]"
309
- )}
310
- style={{
311
- top: `${calendarPosition.top}px`,
312
- left: `${calendarPosition.left}px`,
313
- backgroundColor: theme.card.backgroundColor,
314
- borderColor: theme.card.borderColor,
315
- borderWidth: '1px',
316
- borderStyle: 'solid',
317
- borderRadius: theme.card.borderRadius,
318
- boxShadow: theme.card.shadow,
319
- padding: '0.5rem',
320
- }}
321
- >
322
- <ITCalendar
323
- value={!range ? (startDate as Date) : undefined}
324
- startDate={startDate as Date}
325
- endDate={endDate as Date}
326
- selectionMode={range ? 'range' : 'single'}
327
- onChange={handleDateChange}
328
- minDate={minDate}
329
- maxDate={maxDate}
330
- variant={variant}
331
- className="h-auto border-none shadow-none w-full"
332
- />
333
- </div>
334
- )}
291
+ {isOpen &&
292
+ createPortal(
293
+ <div
294
+ ref={panelRef}
295
+ className={clsx(
296
+ calendarClassName,
297
+ range ? "w-[320px]" : "w-[280px]"
298
+ )}
299
+ style={{
300
+ ...panelStyle,
301
+ backgroundColor: theme.card.backgroundColor,
302
+ borderColor: theme.card.borderColor,
303
+ borderWidth: "1px",
304
+ borderStyle: "solid",
305
+ borderRadius: theme.card.borderRadius,
306
+ boxShadow: theme.card.shadow,
307
+ padding: "0.5rem",
308
+ }}
309
+ >
310
+ <ITCalendar
311
+ value={!range ? (startDate as Date) : undefined}
312
+ startDate={startDate as Date}
313
+ endDate={endDate as Date}
314
+ selectionMode={range ? 'range' : 'single'}
315
+ onChange={handleDateChange}
316
+ minDate={minDate}
317
+ maxDate={maxDate}
318
+ variant={variant}
319
+ className="h-auto border-none shadow-none w-full"
320
+ />
321
+ </div>,
322
+ document.body
323
+ )}
335
324
  </div>
336
325
  );
337
326
  }
@@ -0,0 +1,46 @@
1
+ import { Meta, Title, Description, Primary, Controls, Stories } from '@storybook/blocks';
2
+ import * as StoriesData from './dropdown-menu.stories';
3
+
4
+ <Meta of={StoriesData} />
5
+
6
+ # ITDropdownMenu
7
+
8
+ Action menu with keyboard navigation and ARIA menu semantics. Rendered through a portal, so it is not clipped by cards or `overflow: hidden` ancestors.
9
+
10
+ ## Props
11
+
12
+ | Prop | Type | Default | Description |
13
+ |------|------|---------|-------------|
14
+ | `items` | `ITDropdownMenuItem[]` | — | Actions: `{ id, label, icon?, shortcut?, onClick?, disabled?, danger?, divider? }`. |
15
+ | `trigger` | `ReactNode` | ellipsis icon | Custom trigger content. |
16
+ | `triggerLabel` | `string` | `"Abrir menú"` | Accessible label for the trigger. |
17
+ | `placement` | `"bottom-start" \| "bottom-end" \| "top-start" \| "top-end"` | `"bottom-end"` | Menu placement. |
18
+ | `onSelect` | `(id, item) => void` | — | Fired after an item is activated. |
19
+ | `disabled` | `boolean` | `false` | Disables the trigger. |
20
+ | `className` | `string` | — | Extra CSS classes for the trigger. |
21
+ | `menuClassName` | `string` | — | Extra CSS classes for the menu. |
22
+
23
+ ## Usage
24
+
25
+ ```tsx
26
+ import { ITDropdownMenu } from '@axzydev/axzy_ui_system';
27
+
28
+ <ITDropdownMenu
29
+ items={[
30
+ { id: "edit", label: "Editar", icon: <FaEdit />, onClick: edit },
31
+ { id: "delete", label: "Eliminar", icon: <FaTrash />, danger: true, divider: true, onClick: remove },
32
+ ]}
33
+ />
34
+ ```
35
+
36
+ ## Notes
37
+
38
+ - Keyboard: `ArrowUp`/`ArrowDown` navigate, `Enter`/`Space` activate, `Esc` closes, `Home`/`End` jump.
39
+ - Uses `role="menu"` / `role="menuitem"` and `aria-haspopup` / `aria-expanded` on the trigger.
40
+ - The panel flips above the trigger automatically when there is no room below.
41
+
42
+ ## Stories
43
+
44
+ <Primary />
45
+ <Controls />
46
+ <Stories />