@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,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
|
-
|
|
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 />
|