@axzydev/axzy_ui_system 1.2.16 → 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.
- package/dist/index.cjs +3058 -1407
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.cts +620 -54
- package/dist/index.d.ts +620 -54
- package/dist/index.js +2974 -1318
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/App.tsx +28 -0
- package/src/components/atoms/badget/badget.tsx +2 -4
- package/src/components/atoms/chip/chip.doc.mdx +47 -0
- package/src/components/atoms/chip/chip.props.ts +32 -0
- package/src/components/atoms/chip/chip.stories.tsx +64 -0
- package/src/components/atoms/chip/chip.tsx +112 -0
- package/src/components/atoms/grid/grid.tsx +1 -2
- package/src/components/molecules/accordion/accordion.doc.mdx +47 -0
- package/src/components/molecules/accordion/accordion.props.ts +35 -0
- package/src/components/molecules/accordion/accordion.stories.tsx +68 -0
- package/src/components/molecules/accordion/accordion.tsx +123 -0
- package/src/components/molecules/chip-input/chip-input.doc.mdx +44 -0
- package/src/components/molecules/chip-input/chip-input.props.ts +42 -0
- package/src/components/molecules/chip-input/chip-input.stories.tsx +52 -0
- package/src/components/molecules/chip-input/chip-input.tsx +205 -0
- package/src/components/molecules/date-picker/datePicker.tsx +45 -56
- package/src/components/molecules/dropdown-menu/dropdown-menu.doc.mdx +46 -0
- package/src/components/molecules/dropdown-menu/dropdown-menu.props.ts +41 -0
- package/src/components/molecules/dropdown-menu/dropdown-menu.stories.tsx +41 -0
- package/src/components/molecules/dropdown-menu/dropdown-menu.tsx +219 -0
- package/src/components/molecules/field/field.doc.mdx +43 -0
- package/src/components/molecules/field/field.props.ts +23 -0
- package/src/components/molecules/field/field.stories.tsx +42 -0
- package/src/components/molecules/field/field.tsx +54 -0
- package/src/components/molecules/multi-select/multi-select.doc.mdx +55 -0
- package/src/components/molecules/multi-select/multi-select.props.ts +65 -0
- package/src/components/molecules/multi-select/multi-select.stories.tsx +59 -0
- package/src/components/molecules/multi-select/multi-select.tsx +386 -0
- package/src/components/molecules/search-select/search-select.doc.mdx +5 -0
- package/src/components/molecules/search-select/search-select.props.ts +10 -1
- package/src/components/molecules/search-select/search-select.stories.tsx +18 -0
- package/src/components/molecules/search-select/search-select.tsx +102 -28
- package/src/components/molecules/time-picker/timePicker.tsx +14 -26
- package/src/components/molecules/wysiwyg/wysiwyg.props.ts +51 -0
- package/src/components/molecules/wysiwyg/wysiwyg.stories.tsx +95 -0
- package/src/components/molecules/wysiwyg/wysiwyg.tsx +609 -0
- package/src/components/organisms/form-builder/fieldRenderer.tsx +144 -27
- package/src/components/organisms/form-builder/formBuilder.context.tsx +77 -15
- package/src/components/organisms/form-builder/formBuilder.doc.mdx +213 -86
- package/src/components/organisms/form-builder/formBuilder.props.ts +118 -36
- package/src/components/organisms/form-builder/formBuilder.stories.tsx +694 -96
- package/src/components/organisms/form-builder/formBuilder.tsx +152 -69
- package/src/components/organisms/form-builder/useFormBuilder.ts +59 -21
- package/src/hooks/useFloatingPanel.ts +100 -0
- package/src/index.ts +24 -4
- package/src/showcases/DataShowcases.tsx +76 -0
- package/src/showcases/FormShowcases.tsx +396 -10
- package/src/showcases/HomeShowcase.tsx +6 -6
- package/src/showcases/NavigationShowcases.tsx +72 -2
- package/src/showcases/StructureShowcases.tsx +64 -0
- package/src/types/chip.types.ts +13 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Meta, Title, Description, Primary, Controls, Stories } from '@storybook/blocks';
|
|
2
|
+
import * as StoriesData from './multi-select.stories';
|
|
3
|
+
|
|
4
|
+
<Meta of={StoriesData} />
|
|
5
|
+
|
|
6
|
+
# ITMultiSelect
|
|
7
|
+
|
|
8
|
+
Multi-value select with searchable dropdown, removable chips, and keyboard navigation.
|
|
9
|
+
|
|
10
|
+
## Props
|
|
11
|
+
|
|
12
|
+
| Prop | Type | Default | Description |
|
|
13
|
+
|------|------|---------|-------------|
|
|
14
|
+
| `options` | `ITMultiSelectOption[]` | — | Available options `{ label, value, ...custom }`. |
|
|
15
|
+
| `value` | `(string \| number)[]` | — | Selected values (controlled). |
|
|
16
|
+
| `onChange` | `(values, options) => void` | — | Fired with the next selected values. |
|
|
17
|
+
| `label` | `string` | — | Label above the control. |
|
|
18
|
+
| `placeholder` | `string` | `"Selecciona opciones"` | Placeholder when empty. |
|
|
19
|
+
| `valueField` | `string` | `"value"` | Field used as the option value. |
|
|
20
|
+
| `labelField` | `string` | `"label"` | Field used as the option label. |
|
|
21
|
+
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Control size. |
|
|
22
|
+
| `maxVisibleChips` | `number` | `3` | Chips shown before collapsing into `+N`. |
|
|
23
|
+
| `clearable` | `boolean` | `true` | Shows a clear-all (X) button. |
|
|
24
|
+
| `searchable` | `boolean` | `true` | Enables the search input. |
|
|
25
|
+
| `onSearch` | `(query) => void` | — | Server-side search callback. |
|
|
26
|
+
| `isLoading` | `boolean` | `false` | Loading state for remote options. |
|
|
27
|
+
| `noResultsMessage` | `string` | `"No se encontraron resultados"` | Empty-results message. |
|
|
28
|
+
| `renderOption` | `(option, { isSelected, searchTerm }) => ReactNode` | — | Custom option template. |
|
|
29
|
+
| `disabled` / `readOnly` / `required` / `touched` / `error` | — | — | Standard form states. |
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
import { ITMultiSelect } from '@axzydev/axzy_ui_system';
|
|
35
|
+
|
|
36
|
+
<ITMultiSelect
|
|
37
|
+
label="Skills"
|
|
38
|
+
options={skills}
|
|
39
|
+
value={selected}
|
|
40
|
+
onChange={(values) => setSelected(values)}
|
|
41
|
+
maxVisibleChips={3}
|
|
42
|
+
/>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Notes
|
|
46
|
+
|
|
47
|
+
- Keyboard: `ArrowUp`/`ArrowDown` navigate, `Enter` toggles, `Backspace` removes the last chip, `Esc` closes.
|
|
48
|
+
- `role="combobox"` + `role="listbox"` (`aria-multiselectable`) for accessibility.
|
|
49
|
+
- The dropdown is portaled, so it is not clipped inside cards or `overflow: hidden` ancestors.
|
|
50
|
+
|
|
51
|
+
## Stories
|
|
52
|
+
|
|
53
|
+
<Primary />
|
|
54
|
+
<Controls />
|
|
55
|
+
<Stories />
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
import { SizesTypes } from "@/types/sizes.types";
|
|
3
|
+
|
|
4
|
+
/** Represents an option in the multi-select dropdown. */
|
|
5
|
+
export interface ITMultiSelectOption {
|
|
6
|
+
/** Display label for the option. */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Value associated with the option. */
|
|
9
|
+
value: string | number;
|
|
10
|
+
/** Additional custom fields can be attached. */
|
|
11
|
+
[key: string]: any;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Props for the ITMultiSelect component. */
|
|
15
|
+
export interface ITMultiSelectProps {
|
|
16
|
+
/** Name attribute for form integrations. */
|
|
17
|
+
name?: string;
|
|
18
|
+
/** Label displayed above the control. */
|
|
19
|
+
label?: string;
|
|
20
|
+
/** Placeholder shown when no value is selected. @default "Selecciona opciones" */
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
/** Available options. */
|
|
23
|
+
options: ITMultiSelectOption[];
|
|
24
|
+
/** Selected values (controlled). */
|
|
25
|
+
value: (string | number)[];
|
|
26
|
+
/** Fired with the next selected values and their option objects. */
|
|
27
|
+
onChange?: (values: (string | number)[], options: ITMultiSelectOption[]) => void;
|
|
28
|
+
/** Fired when the control loses focus. */
|
|
29
|
+
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
|
30
|
+
/** Field used as the option value. @default "value" */
|
|
31
|
+
valueField?: string;
|
|
32
|
+
/** Field used as the option display label. @default "label" */
|
|
33
|
+
labelField?: string;
|
|
34
|
+
/** Disables the control. @default false */
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
/** Read-only mode: chips shown, no editing. @default false */
|
|
37
|
+
readOnly?: boolean;
|
|
38
|
+
/** Marks the field as required (red asterisk). @default false */
|
|
39
|
+
required?: boolean;
|
|
40
|
+
/** Whether the field has been touched (form validation). */
|
|
41
|
+
touched?: boolean;
|
|
42
|
+
/** Error message or boolean indicating an error state. */
|
|
43
|
+
error?: string | boolean;
|
|
44
|
+
/** Control size. Valid values: `"sm"`, `"md"`, `"lg"`. @default "md" */
|
|
45
|
+
size?: SizesTypes;
|
|
46
|
+
/** Maximum number of chips shown before collapsing into "+N". @default 3 */
|
|
47
|
+
maxVisibleChips?: number;
|
|
48
|
+
/** Shows a clear-all (X) button. @default true */
|
|
49
|
+
clearable?: boolean;
|
|
50
|
+
/** Enables the search input inside the control. @default true */
|
|
51
|
+
searchable?: boolean;
|
|
52
|
+
/** Callback for server-side search. Receives the query string. */
|
|
53
|
+
onSearch?: (query: string) => void;
|
|
54
|
+
/** Whether options are loading from an external source. @default false */
|
|
55
|
+
isLoading?: boolean;
|
|
56
|
+
/** Message shown when no options match. @default "No se encontraron resultados" */
|
|
57
|
+
noResultsMessage?: string;
|
|
58
|
+
/** Custom template for each dropdown option. */
|
|
59
|
+
renderOption?: (
|
|
60
|
+
option: ITMultiSelectOption,
|
|
61
|
+
state: { isSelected: boolean; searchTerm: string }
|
|
62
|
+
) => ReactNode;
|
|
63
|
+
/** Additional CSS classes for the container. */
|
|
64
|
+
className?: string;
|
|
65
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import ITMultiSelect from "@/components/molecules/multi-select/multi-select";
|
|
4
|
+
import { ITMultiSelectOption } from "./multi-select.props";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof ITMultiSelect> = {
|
|
7
|
+
title: "Components/Form Elements/ITMultiSelect",
|
|
8
|
+
component: ITMultiSelect,
|
|
9
|
+
parameters: { layout: "centered" },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof ITMultiSelect>;
|
|
14
|
+
|
|
15
|
+
const options: ITMultiSelectOption[] = [
|
|
16
|
+
{ value: "mx", label: "México" },
|
|
17
|
+
{ value: "es", label: "España" },
|
|
18
|
+
{ value: "co", label: "Colombia" },
|
|
19
|
+
{ value: "ar", label: "Argentina" },
|
|
20
|
+
{ value: "pe", label: "Perú" },
|
|
21
|
+
{ value: "cl", label: "Chile" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const Wrapper = (args: any) => {
|
|
25
|
+
const [value, setValue] = useState<(string | number)[]>(args.value ?? []);
|
|
26
|
+
return (
|
|
27
|
+
<div className="w-[400px]">
|
|
28
|
+
<ITMultiSelect {...args} value={value} onChange={(v) => setValue(v)} />
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const Default: Story = {
|
|
34
|
+
render: (args) => <Wrapper {...args} />,
|
|
35
|
+
args: { name: "countries", label: "Países", options, value: [] },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const WithPreselected: Story = {
|
|
39
|
+
render: (args) => <Wrapper {...args} />,
|
|
40
|
+
args: { name: "countries", label: "Países", options, value: ["mx", "es", "co", "ar"] },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const WithError: Story = {
|
|
44
|
+
render: (args) => <Wrapper {...args} />,
|
|
45
|
+
args: {
|
|
46
|
+
name: "countries",
|
|
47
|
+
label: "Países",
|
|
48
|
+
options,
|
|
49
|
+
value: [],
|
|
50
|
+
required: true,
|
|
51
|
+
touched: true,
|
|
52
|
+
error: true,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const Disabled: Story = {
|
|
57
|
+
render: (args) => <Wrapper {...args} />,
|
|
58
|
+
args: { name: "countries", label: "Países", options, value: ["mx"], disabled: true },
|
|
59
|
+
};
|