@axzydev/axzy_ui_system 1.2.16 → 1.3.1

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 (65) hide show
  1. package/dist/index.cjs +3349 -1457
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1 -1
  4. package/dist/index.d.cts +719 -54
  5. package/dist/index.d.ts +719 -54
  6. package/dist/index.js +3283 -1387
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/App.tsx +32 -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/calendar/calendar.doc.mdx +2 -1
  21. package/src/components/molecules/calendar/calendar.tsx +102 -24
  22. package/src/components/molecules/chip-input/chip-input.doc.mdx +44 -0
  23. package/src/components/molecules/chip-input/chip-input.props.ts +42 -0
  24. package/src/components/molecules/chip-input/chip-input.stories.tsx +52 -0
  25. package/src/components/molecules/chip-input/chip-input.tsx +205 -0
  26. package/src/components/molecules/date-picker/datePicker.tsx +45 -56
  27. package/src/components/molecules/dropdown-menu/dropdown-menu.doc.mdx +46 -0
  28. package/src/components/molecules/dropdown-menu/dropdown-menu.props.ts +41 -0
  29. package/src/components/molecules/dropdown-menu/dropdown-menu.stories.tsx +41 -0
  30. package/src/components/molecules/dropdown-menu/dropdown-menu.tsx +219 -0
  31. package/src/components/molecules/field/field.doc.mdx +43 -0
  32. package/src/components/molecules/field/field.props.ts +23 -0
  33. package/src/components/molecules/field/field.stories.tsx +42 -0
  34. package/src/components/molecules/field/field.tsx +54 -0
  35. package/src/components/molecules/masked-input/masked-input.doc.mdx +78 -0
  36. package/src/components/molecules/masked-input/masked-input.props.ts +58 -0
  37. package/src/components/molecules/masked-input/masked-input.stories.tsx +126 -0
  38. package/src/components/molecules/masked-input/masked-input.tsx +244 -0
  39. package/src/components/molecules/multi-select/multi-select.doc.mdx +55 -0
  40. package/src/components/molecules/multi-select/multi-select.props.ts +65 -0
  41. package/src/components/molecules/multi-select/multi-select.stories.tsx +59 -0
  42. package/src/components/molecules/multi-select/multi-select.tsx +386 -0
  43. package/src/components/molecules/search-select/search-select.doc.mdx +5 -0
  44. package/src/components/molecules/search-select/search-select.props.ts +10 -1
  45. package/src/components/molecules/search-select/search-select.stories.tsx +18 -0
  46. package/src/components/molecules/search-select/search-select.tsx +102 -28
  47. package/src/components/molecules/time-picker/timePicker.tsx +14 -26
  48. package/src/components/molecules/wysiwyg/wysiwyg.props.ts +51 -0
  49. package/src/components/molecules/wysiwyg/wysiwyg.stories.tsx +95 -0
  50. package/src/components/molecules/wysiwyg/wysiwyg.tsx +609 -0
  51. package/src/components/organisms/form-builder/fieldRenderer.tsx +144 -27
  52. package/src/components/organisms/form-builder/formBuilder.context.tsx +77 -15
  53. package/src/components/organisms/form-builder/formBuilder.doc.mdx +213 -86
  54. package/src/components/organisms/form-builder/formBuilder.props.ts +118 -36
  55. package/src/components/organisms/form-builder/formBuilder.stories.tsx +694 -96
  56. package/src/components/organisms/form-builder/formBuilder.tsx +152 -69
  57. package/src/components/organisms/form-builder/useFormBuilder.ts +59 -21
  58. package/src/hooks/useFloatingPanel.ts +100 -0
  59. package/src/index.ts +26 -4
  60. package/src/showcases/DataShowcases.tsx +76 -0
  61. package/src/showcases/FormShowcases.tsx +574 -10
  62. package/src/showcases/HomeShowcase.tsx +6 -6
  63. package/src/showcases/NavigationShowcases.tsx +72 -2
  64. package/src/showcases/StructureShowcases.tsx +64 -0
  65. package/src/types/chip.types.ts +13 -0
@@ -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 />
@@ -0,0 +1,41 @@
1
+ import { ReactNode } from "react";
2
+
3
+ /** A single action inside {@link ITDropdownMenuProps}. */
4
+ export interface ITDropdownMenuItem {
5
+ /** Unique identifier for the item. */
6
+ id: string;
7
+ /** Item content (usually text). */
8
+ label: ReactNode;
9
+ /** Optional leading icon. */
10
+ icon?: ReactNode;
11
+ /** Optional shortcut hint rendered on the right (e.g. `"⌘K"`). */
12
+ shortcut?: string;
13
+ /** Click handler for the item. */
14
+ onClick?: () => void;
15
+ /** Disables the item. @default false */
16
+ disabled?: boolean;
17
+ /** Renders the item in the danger color (destructive actions). @default false */
18
+ danger?: boolean;
19
+ /** Draws a separator above this item. @default false */
20
+ divider?: boolean;
21
+ }
22
+
23
+ /** Props for the ITDropdownMenu component. */
24
+ export interface ITDropdownMenuProps {
25
+ /** Menu actions. */
26
+ items: ITDropdownMenuItem[];
27
+ /** Custom trigger content. Defaults to a vertical ellipsis icon. */
28
+ trigger?: ReactNode;
29
+ /** Accessible label for the trigger button. @default "Abrir menú" */
30
+ triggerLabel?: string;
31
+ /** Placement of the menu relative to the trigger. Valid values: `"bottom-start"`, `"bottom-end"`, `"top-start"`, `"top-end"`. @default "bottom-end" */
32
+ placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end";
33
+ /** Fired after an item is activated, with its id and definition. */
34
+ onSelect?: (id: string, item: ITDropdownMenuItem) => void;
35
+ /** Disables the trigger. @default false */
36
+ disabled?: boolean;
37
+ /** Additional CSS classes for the trigger button. */
38
+ className?: string;
39
+ /** Additional CSS classes for the menu panel. */
40
+ menuClassName?: string;
41
+ }
@@ -0,0 +1,41 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { FaEdit, FaCopy, FaTrash, FaDownload } from "react-icons/fa";
3
+ import ITDropdownMenu from "@/components/molecules/dropdown-menu/dropdown-menu";
4
+ import ITButton from "@/components/atoms/button/button";
5
+
6
+ const meta: Meta<typeof ITDropdownMenu> = {
7
+ title: "Components/Navigation/ITDropdownMenu",
8
+ component: ITDropdownMenu,
9
+ parameters: { layout: "centered" },
10
+ };
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof ITDropdownMenu>;
14
+
15
+ const items = [
16
+ { id: "edit", label: "Editar", icon: <FaEdit />, onClick: () => alert("Editar") },
17
+ { id: "copy", label: "Duplicar", icon: <FaCopy />, shortcut: "⌘D", onClick: () => alert("Duplicar") },
18
+ { id: "download", label: "Descargar", icon: <FaDownload />, disabled: true },
19
+ { id: "delete", label: "Eliminar", icon: <FaTrash />, danger: true, divider: true, onClick: () => alert("Eliminar") },
20
+ ];
21
+
22
+ export const Default: Story = {
23
+ args: { items },
24
+ };
25
+
26
+ export const CustomTrigger: Story = {
27
+ args: {
28
+ items,
29
+ trigger: <span className="text-sm font-semibold px-1">Acciones</span>,
30
+ triggerLabel: "Acciones",
31
+ },
32
+ };
33
+
34
+ export const WithLabelButton: Story = {
35
+ render: () => (
36
+ <div className="flex items-center gap-4">
37
+ <ITButton label="Guardar" variant="filled" color="primary" size="sm" />
38
+ <ITDropdownMenu items={items} />
39
+ </div>
40
+ ),
41
+ };
@@ -0,0 +1,219 @@
1
+ import clsx from "clsx";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { FaEllipsisV } from "react-icons/fa";
5
+ import { ITDropdownMenuProps } from "./dropdown-menu.props";
6
+ import { useFloatingPanel } from "@/hooks/useFloatingPanel";
7
+ import ITText from "@/components/atoms/text/text";
8
+
9
+ /**
10
+ * Action menu with keyboard navigation and ARIA menu semantics.
11
+ *
12
+ * Renders the menu through a portal so it escapes `overflow: hidden` and
13
+ * transformed ancestors, and flips above the trigger when there is no room
14
+ * below.
15
+ *
16
+ * @example
17
+ * <ITDropdownMenu
18
+ * items={[
19
+ * { id: "edit", label: "Editar", icon: <FaEdit />, onClick: edit },
20
+ * { id: "delete", label: "Eliminar", icon: <FaTrash />, danger: true, divider: true, onClick: remove },
21
+ * ]}
22
+ * />
23
+ */
24
+ export default function ITDropdownMenu({
25
+ items,
26
+ trigger,
27
+ triggerLabel = "Abrir menú",
28
+ placement = "bottom-end",
29
+ onSelect,
30
+ disabled = false,
31
+ className,
32
+ menuClassName,
33
+ }: ITDropdownMenuProps) {
34
+ const [isOpen, setIsOpen] = useState(false);
35
+ const [activeIndex, setActiveIndex] = useState(-1);
36
+
37
+ const triggerRef = useRef<HTMLButtonElement>(null);
38
+ const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
39
+
40
+ const { panelRef, style: panelStyle } = useFloatingPanel(triggerRef, isOpen, {
41
+ estimatedHeight: Math.max(items.length * 40, 120),
42
+ matchWidth: false,
43
+ align: placement.endsWith("end") ? "end" : "start",
44
+ });
45
+
46
+ const enabledIndexes = useMemo(
47
+ () => items.map((item, i) => (item.disabled ? -1 : i)).filter((i) => i >= 0),
48
+ [items]
49
+ );
50
+
51
+ useEffect(() => {
52
+ if (!isOpen) return;
53
+ function handleClickOutside(event: MouseEvent) {
54
+ const target = event.target as Node;
55
+ if (!triggerRef.current?.contains(target) && !panelRef.current?.contains(target)) {
56
+ setIsOpen(false);
57
+ }
58
+ }
59
+ document.addEventListener("mousedown", handleClickOutside);
60
+ return () => document.removeEventListener("mousedown", handleClickOutside);
61
+ }, [isOpen, panelRef]);
62
+
63
+ useEffect(() => {
64
+ if (isOpen) {
65
+ setActiveIndex(enabledIndexes[0] ?? -1);
66
+ requestAnimationFrame(() => {
67
+ if (enabledIndexes[0] !== undefined) itemRefs.current[enabledIndexes[0]]?.focus();
68
+ });
69
+ }
70
+ }, [isOpen, enabledIndexes]);
71
+
72
+ const open = () => {
73
+ if (disabled) return;
74
+ setIsOpen(true);
75
+ };
76
+
77
+ const close = (restoreFocus = true) => {
78
+ setIsOpen(false);
79
+ setActiveIndex(-1);
80
+ if (restoreFocus) triggerRef.current?.focus();
81
+ };
82
+
83
+ const activate = (index: number) => {
84
+ const item = items[index];
85
+ if (!item || item.disabled) return;
86
+ item.onClick?.();
87
+ onSelect?.(item.id, item);
88
+ close();
89
+ };
90
+
91
+ const moveActive = (dir: 1 | -1) => {
92
+ if (!enabledIndexes.length) return;
93
+ const currentPos = enabledIndexes.indexOf(activeIndex);
94
+ const nextPos =
95
+ currentPos === -1
96
+ ? dir === 1
97
+ ? 0
98
+ : enabledIndexes.length - 1
99
+ : (currentPos + dir + enabledIndexes.length) % enabledIndexes.length;
100
+ const next = enabledIndexes[nextPos];
101
+ setActiveIndex(next);
102
+ itemRefs.current[next]?.focus();
103
+ };
104
+
105
+ const handleTriggerKeyDown = (e: React.KeyboardEvent) => {
106
+ if (disabled) return;
107
+ if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
108
+ e.preventDefault();
109
+ if (!isOpen) open();
110
+ else moveActive(1);
111
+ } else if (e.key === "ArrowUp") {
112
+ e.preventDefault();
113
+ if (!isOpen) open();
114
+ else moveActive(-1);
115
+ } else if (e.key === "Escape") {
116
+ close();
117
+ }
118
+ };
119
+
120
+ const handleMenuKeyDown = (e: React.KeyboardEvent) => {
121
+ if (e.key === "ArrowDown") {
122
+ e.preventDefault();
123
+ moveActive(1);
124
+ } else if (e.key === "ArrowUp") {
125
+ e.preventDefault();
126
+ moveActive(-1);
127
+ } else if (e.key === "Home") {
128
+ e.preventDefault();
129
+ if (enabledIndexes.length) {
130
+ const first = enabledIndexes[0];
131
+ setActiveIndex(first);
132
+ itemRefs.current[first]?.focus();
133
+ }
134
+ } else if (e.key === "End") {
135
+ e.preventDefault();
136
+ const last = enabledIndexes[enabledIndexes.length - 1];
137
+ if (last !== undefined) {
138
+ setActiveIndex(last);
139
+ itemRefs.current[last]?.focus();
140
+ }
141
+ } else if (e.key === "Escape") {
142
+ e.preventDefault();
143
+ close();
144
+ } else if (e.key === "Tab") {
145
+ close(false);
146
+ }
147
+ };
148
+
149
+ return (
150
+ <>
151
+ <button
152
+ ref={triggerRef}
153
+ type="button"
154
+ aria-haspopup="menu"
155
+ aria-expanded={isOpen}
156
+ aria-label={triggerLabel}
157
+ disabled={disabled}
158
+ onClick={() => (isOpen ? close() : open())}
159
+ onKeyDown={handleTriggerKeyDown}
160
+ className={clsx(
161
+ "inline-flex items-center justify-center rounded-lg p-2 text-secondary-500 transition-colors",
162
+ "hover:bg-secondary-100 hover:text-secondary-700 dark:hover:bg-slate-800 dark:hover:text-slate-200",
163
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400",
164
+ disabled && "opacity-50 cursor-not-allowed",
165
+ className
166
+ )}
167
+ >
168
+ {trigger ?? <FaEllipsisV size={14} />}
169
+ </button>
170
+
171
+ {isOpen &&
172
+ createPortal(
173
+ <div
174
+ ref={panelRef}
175
+ role="menu"
176
+ aria-label={triggerLabel}
177
+ style={panelStyle}
178
+ onKeyDown={handleMenuKeyDown}
179
+ className={clsx(
180
+ "min-w-[180px] rounded-xl border border-secondary-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-xl py-1 overflow-hidden",
181
+ menuClassName
182
+ )}
183
+ >
184
+ {items.map((item, index) => (
185
+ <div key={item.id}>
186
+ {item.divider && <div className="my-1 h-px bg-secondary-100 dark:bg-slate-800" />}
187
+ <button
188
+ ref={(el) => { itemRefs.current[index] = el; }}
189
+ type="button"
190
+ role="menuitem"
191
+ tabIndex={-1}
192
+ disabled={item.disabled}
193
+ onClick={() => activate(index)}
194
+ onMouseEnter={() => !item.disabled && setActiveIndex(index)}
195
+ className={clsx(
196
+ "w-full flex items-center gap-3 px-3 py-2 text-left text-sm transition-colors",
197
+ "focus:outline-none",
198
+ item.disabled
199
+ ? "opacity-50 cursor-not-allowed text-secondary-400"
200
+ : item.danger
201
+ ? "text-danger-600 hover:bg-danger-50 focus:bg-danger-50 dark:hover:bg-danger-950/30"
202
+ : "text-secondary-700 dark:text-slate-200 hover:bg-secondary-50 focus:bg-secondary-50 dark:hover:bg-slate-800 dark:focus:bg-slate-800",
203
+ activeIndex === index && !item.disabled && !item.danger && "bg-secondary-50 dark:bg-slate-800"
204
+ )}
205
+ >
206
+ {item.icon && <span className="shrink-0">{item.icon}</span>}
207
+ <ITText as="span" className="flex-1 truncate">{item.label}</ITText>
208
+ {item.shortcut && (
209
+ <ITText as="span" className="text-xs text-secondary-400 font-mono">{item.shortcut}</ITText>
210
+ )}
211
+ </button>
212
+ </div>
213
+ ))}
214
+ </div>,
215
+ document.body
216
+ )}
217
+ </>
218
+ );
219
+ }
@@ -0,0 +1,43 @@
1
+ import { Meta, Title, Description, Primary, Controls, Stories } from '@storybook/blocks';
2
+ import * as StoriesData from './field.stories';
3
+
4
+ <Meta of={StoriesData} />
5
+
6
+ # ITField
7
+
8
+ Form-field wrapper that standardizes label, required marker, helper text, and error message around any control.
9
+
10
+ ## Props
11
+
12
+ | Prop | Type | Default | Description |
13
+ |------|------|---------|-------------|
14
+ | `label` | `string` | — | Label above the control. |
15
+ | `htmlFor` | `string` | — | `id` the label points to. |
16
+ | `required` | `boolean` | `false` | Shows a red asterisk. |
17
+ | `error` | `string \| boolean` | — | Error message, or `true` for the generic message. |
18
+ | `helpText` | `string` | — | Helper text shown when there is no error. |
19
+ | `children` | `ReactNode` | — | The form control. |
20
+ | `className` | `string` | — | Extra CSS classes for the wrapper. |
21
+ | `labelClassName` | `string` | — | Extra CSS classes for the label. |
22
+ | `contentClassName` | `string` | — | Extra CSS classes for the control container. |
23
+
24
+ ## Usage
25
+
26
+ ```tsx
27
+ import { ITField, ITInput } from '@axzydev/axzy_ui_system';
28
+
29
+ <ITField label="Email" htmlFor="email" required error={errors.email} helpText="We never share it.">
30
+ <ITInput name="email" value={email} onChange={onChange} />
31
+ </ITField>
32
+ ```
33
+
34
+ ## Notes
35
+
36
+ - Error text takes precedence over `helpText`; only one is shown at a time.
37
+ - `error={true}` renders the generic "Este campo es requerido" message.
38
+
39
+ ## Stories
40
+
41
+ <Primary />
42
+ <Controls />
43
+ <Stories />
@@ -0,0 +1,23 @@
1
+ import { ReactNode } from "react";
2
+
3
+ /** Props for the ITField form-field wrapper. */
4
+ export interface ITFieldProps {
5
+ /** Label rendered above the control. */
6
+ label?: string;
7
+ /** `id` of the control the label points to (`htmlFor`). */
8
+ htmlFor?: string;
9
+ /** Shows a red asterisk next to the label. @default false */
10
+ required?: boolean;
11
+ /** Validation error. Pass `true` for the generic message or a string for a custom one. */
12
+ error?: string | boolean;
13
+ /** Helper text shown below the control when there is no error. */
14
+ helpText?: string;
15
+ /** The form control (ITInput, ITSelect, ITTextarea, …). */
16
+ children: ReactNode;
17
+ /** Additional CSS classes for the wrapper. */
18
+ className?: string;
19
+ /** Additional CSS classes for the label. */
20
+ labelClassName?: string;
21
+ /** Additional CSS classes for the control container. */
22
+ contentClassName?: string;
23
+ }
@@ -0,0 +1,42 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import ITField from "@/components/molecules/field/field";
3
+ import ITInput from "@/components/atoms/input/input";
4
+
5
+ const meta: Meta<typeof ITField> = {
6
+ title: "Components/Form Elements/ITField",
7
+ component: ITField,
8
+ parameters: { layout: "centered" },
9
+ };
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof ITField>;
13
+
14
+ export const Default: Story = {
15
+ render: () => (
16
+ <div className="w-[360px]">
17
+ <ITField label="Nombre completo" htmlFor="name" required helpText="Como aparece en tu identificación.">
18
+ <ITInput name="name" value="" onChange={() => {}} onBlur={() => {}} />
19
+ </ITField>
20
+ </div>
21
+ ),
22
+ };
23
+
24
+ export const WithError: Story = {
25
+ render: () => (
26
+ <div className="w-[360px]">
27
+ <ITField label="Correo" htmlFor="email" required error="Ingresa un correo válido.">
28
+ <ITInput name="email" value="correo-malo" onChange={() => {}} onBlur={() => {}} />
29
+ </ITField>
30
+ </div>
31
+ ),
32
+ };
33
+
34
+ export const GenericError: Story = {
35
+ render: () => (
36
+ <div className="w-[360px]">
37
+ <ITField label="RFC" htmlFor="rfc" required error>
38
+ <ITInput name="rfc" value="" onChange={() => {}} onBlur={() => {}} />
39
+ </ITField>
40
+ </div>
41
+ ),
42
+ };
@@ -0,0 +1,54 @@
1
+ import clsx from "clsx";
2
+ import { ITFieldProps } from "./field.props";
3
+ import { inputError, inputLabel } from "@/utils/styles";
4
+ import ITText from "@/components/atoms/text/text";
5
+
6
+ /**
7
+ * Form-field wrapper that standardizes label, required marker, helper text,
8
+ * and error message around any control.
9
+ *
10
+ * Keeps spacing and error styling consistent across forms so each control
11
+ * does not re-implement label/error logic.
12
+ *
13
+ * @example
14
+ * <ITField label="Email" htmlFor="email" required error={errors.email} helpText="We never share it.">
15
+ * <ITInput name="email" value={email} onChange={onChange} />
16
+ * </ITField>
17
+ */
18
+ export default function ITField({
19
+ label,
20
+ htmlFor,
21
+ required = false,
22
+ error,
23
+ helpText,
24
+ children,
25
+ className,
26
+ labelClassName,
27
+ contentClassName,
28
+ }: ITFieldProps) {
29
+ const hasError = Boolean(error);
30
+ const message = typeof error === "string" ? error : hasError ? "Este campo es requerido" : undefined;
31
+
32
+ return (
33
+ <div className={clsx("flex flex-col gap-1.5", className)}>
34
+ {label && (
35
+ <label htmlFor={htmlFor} className={clsx(inputLabel(hasError), labelClassName)}>
36
+ <ITText as="span">{label}</ITText>
37
+ {required && (
38
+ <ITText as="span" className="text-danger-500 ml-1">
39
+ *
40
+ </ITText>
41
+ )}
42
+ </label>
43
+ )}
44
+
45
+ <div className={clsx(contentClassName)}>{children}</div>
46
+
47
+ {hasError ? (
48
+ <ITText as="p" className={inputError}>{message}</ITText>
49
+ ) : helpText ? (
50
+ <ITText as="p" className="text-xs text-secondary-500 dark:text-secondary-400">{helpText}</ITText>
51
+ ) : null}
52
+ </div>
53
+ );
54
+ }