@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.
- package/dist/index.cjs +3349 -1457
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.cts +719 -54
- package/dist/index.d.ts +719 -54
- package/dist/index.js +3283 -1387
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/App.tsx +32 -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/calendar/calendar.doc.mdx +2 -1
- package/src/components/molecules/calendar/calendar.tsx +102 -24
- 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/masked-input/masked-input.doc.mdx +78 -0
- package/src/components/molecules/masked-input/masked-input.props.ts +58 -0
- package/src/components/molecules/masked-input/masked-input.stories.tsx +126 -0
- package/src/components/molecules/masked-input/masked-input.tsx +244 -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 +26 -4
- package/src/showcases/DataShowcases.tsx +76 -0
- package/src/showcases/FormShowcases.tsx +574 -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
|
@@ -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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
+
}
|