@soyfri/shared-library 2.0.0-beta.2 → 2.0.0-beta.4

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 (187) hide show
  1. package/.dockerignore +8 -0
  2. package/.github/workflows/publish.yml +107 -0
  3. package/.prettierrc +3 -0
  4. package/.storybook/main.ts +19 -0
  5. package/.storybook/preview.ts +14 -0
  6. package/.storybook/vitest.setup.ts +9 -0
  7. package/Dockerfile +37 -0
  8. package/build.js +102 -0
  9. package/chromatic.config.json +5 -0
  10. package/cleanDirectories.js +40 -0
  11. package/dist/README.md +243 -0
  12. package/dist/components/Icon/Icon.js +1 -1
  13. package/dist/components/Table/Table.js +1 -1
  14. package/dist/index.cjs +24 -0
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.js +7 -1
  17. package/dist/mui.d.ts +1 -0
  18. package/dist/package.json +197 -0
  19. package/package.json +4 -32
  20. package/rollup.config.cjs +87 -0
  21. package/src/components/ActionMenu/ActionMenu.stories.tsx +230 -0
  22. package/src/components/ActionMenu/ActionMenu.tsx +174 -0
  23. package/src/components/ActionMenu/index.ts +2 -0
  24. package/src/components/AppBar/AppBar.stories.tsx +272 -0
  25. package/src/components/AppBar/AppBar.sx.ts +32 -0
  26. package/src/components/AppBar/AppBar.tsx +123 -0
  27. package/src/components/AppBar/AppBarBrand.tsx +120 -0
  28. package/src/components/AppBar/AppBarContext.ts +25 -0
  29. package/src/components/AppBar/AppBarMenuToggle.tsx +90 -0
  30. package/src/components/AppBar/AppBarUserMenu.tsx +217 -0
  31. package/src/components/AppBar/index.ts +25 -0
  32. package/src/components/Autocomplete/Autocomplete.definitions.ts +477 -0
  33. package/src/components/Autocomplete/Autocomplete.helpers.ts +60 -0
  34. package/src/components/Autocomplete/Autocomplete.stories.tsx +748 -0
  35. package/src/components/Autocomplete/Autocomplete.sx.ts +30 -0
  36. package/src/components/Autocomplete/Autocomplete.tsx +361 -0
  37. package/src/components/Autocomplete/Autocomplete.types.ts +13 -0
  38. package/src/components/Autocomplete/_parts/AutocompleteChips.tsx +55 -0
  39. package/src/components/Autocomplete/_parts/AutocompleteLoader.tsx +17 -0
  40. package/src/components/Autocomplete/_parts/AutocompleteOption.tsx +31 -0
  41. package/src/components/Autocomplete/index.ts +12 -0
  42. package/src/components/Avatar/Avatar.definitions.ts +162 -0
  43. package/src/components/Avatar/Avatar.stories.tsx +258 -0
  44. package/src/components/Avatar/Avatar.tsx +206 -0
  45. package/src/components/Avatar/index.ts +1 -0
  46. package/src/components/Button/Button.definition.ts +97 -0
  47. package/src/components/Button/Button.stories.tsx +285 -0
  48. package/src/components/Button/Button.tsx +67 -0
  49. package/src/components/Button/index.ts +1 -0
  50. package/src/components/Card/Card.definition.ts +5 -0
  51. package/src/components/Card/Card.stories.tsx +221 -0
  52. package/src/components/Card/Card.sx.ts +104 -0
  53. package/src/components/Card/Card.tsx +200 -0
  54. package/src/components/Card/index.ts +9 -0
  55. package/src/components/Chip/Chip.definitions.ts +167 -0
  56. package/src/components/Chip/Chip.stories.tsx +265 -0
  57. package/src/components/Chip/Chip.tsx +61 -0
  58. package/src/components/Chip/index.ts +1 -0
  59. package/src/components/Column/Column.tsx +29 -0
  60. package/src/components/Column/index.ts +1 -0
  61. package/src/components/DatePicker/DatePicker.definitions.ts +228 -0
  62. package/src/components/DatePicker/DatePicker.helpers.ts +24 -0
  63. package/src/components/DatePicker/DatePicker.stories.tsx +309 -0
  64. package/src/components/DatePicker/DatePicker.sx.ts +33 -0
  65. package/src/components/DatePicker/DatePicker.tsx +189 -0
  66. package/src/components/DatePicker/DatePicker.types.ts +10 -0
  67. package/src/components/DatePicker/index.ts +9 -0
  68. package/src/components/DateRangePicker/DateRangePicker.definitions.ts +191 -0
  69. package/src/components/DateRangePicker/DateRangePicker.stories.tsx +252 -0
  70. package/src/components/DateRangePicker/DateRangePicker.tsx +56 -0
  71. package/src/components/DateRangePicker/index.ts +1 -0
  72. package/src/components/DateTimePicker/DateTimePicker.definitions.ts +256 -0
  73. package/src/components/DateTimePicker/DateTimePicker.helpers.ts +38 -0
  74. package/src/components/DateTimePicker/DateTimePicker.stories.tsx +418 -0
  75. package/src/components/DateTimePicker/DateTimePicker.sx.ts +30 -0
  76. package/src/components/DateTimePicker/DateTimePicker.tsx +225 -0
  77. package/src/components/DateTimePicker/DateTimePicker.types.ts +10 -0
  78. package/src/components/DateTimePicker/index.ts +9 -0
  79. package/src/components/Drawer/Drawer.stories.tsx +270 -0
  80. package/src/components/Drawer/Drawer.sx.ts +106 -0
  81. package/src/components/Drawer/Drawer.tsx +214 -0
  82. package/src/components/Drawer/DrawerContext.ts +26 -0
  83. package/src/components/Drawer/DrawerItem.tsx +110 -0
  84. package/src/components/Drawer/index.ts +10 -0
  85. package/src/components/Flyout/Flyout.stories.tsx +282 -0
  86. package/src/components/Flyout/Flyout.tsx +122 -0
  87. package/src/components/Flyout/index.ts +1 -0
  88. package/src/components/Gallery/Gallery.definition.tsx +37 -0
  89. package/src/components/Gallery/Gallery.stories.tsx +82 -0
  90. package/src/components/Gallery/Gallery.tsx +118 -0
  91. package/src/components/Gallery/GalleryLightbox.tsx +170 -0
  92. package/src/components/Gallery/GalleryMain.tsx +84 -0
  93. package/src/components/Gallery/GalleryThumbnails.tsx +106 -0
  94. package/src/components/Gallery/index.ts +1 -0
  95. package/src/components/Icon/Icon.stories.tsx +121 -0
  96. package/src/components/Icon/Icon.tsx +175 -0
  97. package/src/components/Icon/index.ts +2 -0
  98. package/src/components/Input/Input.definitions.ts +324 -0
  99. package/src/components/Input/Input.helpers.ts +49 -0
  100. package/src/components/Input/Input.stories.tsx +499 -0
  101. package/src/components/Input/Input.sx.ts +42 -0
  102. package/src/components/Input/Input.tsx +141 -0
  103. package/src/components/Input/Input.types.ts +10 -0
  104. package/src/components/Input/index.ts +9 -0
  105. package/src/components/InputGroup/InputGroup.definitions.ts +158 -0
  106. package/src/components/InputGroup/InputGroup.stories.tsx +267 -0
  107. package/src/components/InputGroup/InputGroup.tsx +179 -0
  108. package/src/components/InputGroup/index.ts +1 -0
  109. package/src/components/MenuButton/MenuButton.stories.tsx +197 -0
  110. package/src/components/MenuButton/MenuButton.tsx +100 -0
  111. package/src/components/MenuButton/index.ts +1 -0
  112. package/src/components/Modal/Modal.stories.tsx +721 -0
  113. package/src/components/Modal/Modal.tsx +355 -0
  114. package/src/components/Modal/ModalBody.tsx +16 -0
  115. package/src/components/Modal/ModalFooter.tsx +71 -0
  116. package/src/components/Modal/ModalHeader.tsx +18 -0
  117. package/src/components/Modal/index.ts +6 -0
  118. package/src/components/PageLoader/PageLoader.stories.tsx +217 -0
  119. package/src/components/PageLoader/PageLoader.tsx +96 -0
  120. package/src/components/PageLoader/index.ts +2 -0
  121. package/src/components/ScrollTopButton/ScrollTopButton.stories.tsx +158 -0
  122. package/src/components/ScrollTopButton/ScrollTopButton.tsx +135 -0
  123. package/src/components/ScrollTopButton/index.ts +8 -0
  124. package/src/components/ScrollTopButton/scrollToTop.ts +37 -0
  125. package/src/components/Select/Select.definitions.ts +602 -0
  126. package/src/components/Select/Select.helpers.ts +71 -0
  127. package/src/components/Select/Select.stories.tsx +687 -0
  128. package/src/components/Select/Select.sx.ts +14 -0
  129. package/src/components/Select/Select.tsx +429 -0
  130. package/src/components/Select/Select.types.ts +15 -0
  131. package/src/components/Select/_parts/SelectMenuItem.tsx +40 -0
  132. package/src/components/Select/_parts/SelectSearchHeader.tsx +51 -0
  133. package/src/components/Select/_parts/SelectValue.tsx +96 -0
  134. package/src/components/Select/index.ts +14 -0
  135. package/src/components/Stat/Stat.stories.tsx +85 -0
  136. package/src/components/Stat/Stat.tsx +117 -0
  137. package/src/components/Stat/index.ts +2 -0
  138. package/src/components/StatusMessage/StatusMessage.stories.tsx +130 -0
  139. package/src/components/StatusMessage/StatusMessage.tsx +162 -0
  140. package/src/components/StatusMessage/index.ts +2 -0
  141. package/src/components/Stepper/Step.tsx +21 -0
  142. package/src/components/Stepper/Stepper.definition.ts +75 -0
  143. package/src/components/Stepper/Stepper.stories.tsx +122 -0
  144. package/src/components/Stepper/Stepper.tsx +75 -0
  145. package/src/components/Stepper/index.ts +2 -0
  146. package/src/components/Table/EmptyTable.png +0 -0
  147. package/src/components/Table/Table.definition.ts +580 -0
  148. package/src/components/Table/Table.stories.tsx +853 -0
  149. package/src/components/Table/Table.tsx +495 -0
  150. package/src/components/Table/data.ts +134 -0
  151. package/src/components/Table/exportsUtils.ts +195 -0
  152. package/src/components/Table/index.ts +3 -0
  153. package/src/components/Table/types.ts +34 -0
  154. package/src/components/Tabs/Tab.definition.ts +53 -0
  155. package/src/components/Tabs/Tab.tsx +19 -0
  156. package/src/components/Tabs/Tabs.stories.tsx +118 -0
  157. package/src/components/Tabs/Tabs.tsx +99 -0
  158. package/src/components/Tabs/_tabUtils.tsx +4 -0
  159. package/src/components/Tabs/index.ts +2 -0
  160. package/src/components/Timeline/Timeline.definition.ts +43 -0
  161. package/src/components/Timeline/Timeline.stories.tsx +108 -0
  162. package/src/components/Timeline/Timeline.tsx +49 -0
  163. package/src/components/Timeline/TimelineItem.tsx +31 -0
  164. package/src/components/Timeline/index.ts +2 -0
  165. package/src/components/Tooltip/Tooltip.stories.tsx +129 -0
  166. package/src/components/Tooltip/Tooltip.tsx +58 -0
  167. package/src/components/Tooltip/index.ts +1 -0
  168. package/src/components/_shared/formField.sx.ts +118 -0
  169. package/src/components/_shared/resolvePreset.ts +35 -0
  170. package/src/hooks/ClipBoard/ClipBoard.stories.tsx +168 -0
  171. package/src/hooks/ClipBoard/ClipBoard.tsx +131 -0
  172. package/src/hooks/ClipBoard/ClipboardUnifiedDemo.tsx +111 -0
  173. package/src/hooks/ClipBoard/index.ts +1 -0
  174. package/src/hooks/Wizard/Wizard.stories.tsx +301 -0
  175. package/src/hooks/Wizard/WizardContext.tsx +166 -0
  176. package/src/hooks/Wizard/index.ts +6 -0
  177. package/src/hooks/Wizard/useWizard.ts +13 -0
  178. package/src/index.ts +17 -0
  179. package/src/mui.ts +54 -0
  180. package/src/styles.css +3 -0
  181. package/src/theme/componentStyles.ts +47 -0
  182. package/src/theme/tokens.ts +43 -0
  183. package/tailwind.config.js +10 -0
  184. package/tsconfig.json +48 -0
  185. package/tsup.config.js +41 -0
  186. package/vite.config.js +132 -0
  187. package/vitest.config.ts +35 -0
@@ -0,0 +1,14 @@
1
+ import type { SelectProps as MuiSelectProps } from '@mui/material';
2
+
3
+ import { buildFormFieldSx } from '../_shared/formField.sx';
4
+ import type { LabelPosition } from './Select';
5
+
6
+ /**
7
+ * Builder de sx para el Select. Usa el builder compartido
8
+ * `buildFormFieldSx`. El Select no necesita overrides específicos.
9
+ */
10
+ export const buildSelectSx = (
11
+ borderRadius: number | string,
12
+ labelPosition: LabelPosition,
13
+ ): MuiSelectProps['sx'] =>
14
+ buildFormFieldSx({ borderRadius, labelPosition }) as MuiSelectProps['sx'];
@@ -0,0 +1,429 @@
1
+ import React, {
2
+ isValidElement,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ReactElement,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import {
11
+ Box,
12
+ CircularProgress,
13
+ FormControl,
14
+ FormHelperText,
15
+ InputLabel,
16
+ ListSubheader,
17
+ MenuItem,
18
+ OutlinedInput,
19
+ Select as MuiSelect,
20
+ SelectChangeEvent,
21
+ Typography,
22
+ type SelectProps as MuiSelectProps,
23
+ } from '@mui/material';
24
+ import { useTheme } from '@mui/material/styles';
25
+ import { Controller, type Control, type RegisterOptions } from 'react-hook-form';
26
+
27
+ import { buildSelectSx } from './Select.sx';
28
+ import { resolvePreset } from '../_shared/resolvePreset';
29
+ import {
30
+ filterOptionsByLabel,
31
+ groupOptions,
32
+ isGroupedOptionsEmpty,
33
+ isSelectValueEmpty,
34
+ normalizeSelectValue,
35
+ } from './Select.helpers';
36
+ import { SelectSearchHeader } from './_parts/SelectSearchHeader';
37
+ import { SelectValue } from './_parts/SelectValue';
38
+ import { renderSelectMenuItem } from './_parts/SelectMenuItem';
39
+
40
+ // ── Tipos de dominio ─────────────────────────────────────────────────────
41
+ export interface SelectOption {
42
+ value: string | number;
43
+ label: string;
44
+ img?: string;
45
+ disabled?: boolean;
46
+ group?: string;
47
+ [key: string]: any;
48
+ }
49
+
50
+ export type LabelPosition = 'outside' | 'floating';
51
+ export type ChipVariant = 'outlined' | 'filled';
52
+ export type SelectSize = 'small' | 'medium';
53
+
54
+ // ── Render slots ─────────────────────────────────────────────────────────
55
+ export type RenderOptionItem = (item: SelectOption) => ReactNode;
56
+ export type RenderChipLabel = (item: SelectOption) => ReactNode;
57
+
58
+ // ── Sub-componente declarativo <Option> ──────────────────────────────────
59
+ export interface OptionProps {
60
+ children: RenderOptionItem;
61
+ }
62
+
63
+ // ── Props base ───────────────────────────────────────────────────────────
64
+ export interface BaseSelectProps<
65
+ TValue extends SelectOption['value'] = SelectOption['value'],
66
+ > {
67
+ label?: string;
68
+ options?: SelectOption[];
69
+ defaultValue?: TValue | TValue[];
70
+ size?: SelectSize;
71
+ multiple?: boolean;
72
+ filterable?: boolean;
73
+ placeholder?: string;
74
+ children?: ReactElement<{ children: RenderOptionItem }>;
75
+ maxHeight?: number | string;
76
+ maxWidth?: number | string;
77
+ maxChipsToShow?: number;
78
+ renderChipLabel?: RenderChipLabel;
79
+ chipVariant?: ChipVariant;
80
+ loadOptions?: (inputValue: string) => Promise<SelectOption[]>;
81
+ loadingMessage?: ReactNode;
82
+ noOptionsMessage?: ReactNode;
83
+ debounceTimeout?: number;
84
+ disabled?: boolean;
85
+ error?: boolean;
86
+ helperText?: string;
87
+ /** Border radius del input (px o string CSS). Default: 10 */
88
+ borderRadius?: number | string;
89
+ /**
90
+ * Posición del label.
91
+ * - "outside" (default): label arriba del input, sin animación.
92
+ * - "floating": label clásico de MUI (flota dentro del notched outline).
93
+ */
94
+ labelPosition?: LabelPosition;
95
+ /**
96
+ * Nombre del preset de estilo registrado en `theme.styles.Select`.
97
+ * - `"default"` (o ausente) = estilo built-in del paquete.
98
+ * - Cualquier otro string = mergea el preset encima del estilo built-in.
99
+ */
100
+ preset?: string;
101
+ sx?: MuiSelectProps['sx'];
102
+ className?: string;
103
+ }
104
+
105
+ // ── Variantes discriminadas (RHF vs controlado) ──────────────────────────
106
+ export interface RHFSelectProps<
107
+ TValue extends SelectOption['value'] = SelectOption['value'],
108
+ > extends BaseSelectProps<TValue> {
109
+ name: string;
110
+ control: Control<any>;
111
+ validation?: RegisterOptions;
112
+ value?: TValue | TValue[];
113
+ onChange?: (val: TValue | TValue[]) => void;
114
+ }
115
+
116
+ export interface StandardSelectProps<
117
+ TValue extends SelectOption['value'] = SelectOption['value'],
118
+ > extends BaseSelectProps<TValue> {
119
+ name?: string;
120
+ control?: never;
121
+ validation?: never;
122
+ value: TValue | TValue[];
123
+ onChange: (val: TValue | TValue[]) => void;
124
+ }
125
+
126
+ // ── API pública final ────────────────────────────────────────────────────
127
+ export type SelectProps<
128
+ TValue extends SelectOption['value'] = SelectOption['value'],
129
+ > = RHFSelectProps<TValue> | StandardSelectProps<TValue>;
130
+
131
+ // ── Sub-componente declarativo <Option> ──────────────────────────────────
132
+ export function Option(_props: OptionProps) {
133
+ return null;
134
+ }
135
+ Option.displayName = 'Option';
136
+
137
+ // ── Componente ───────────────────────────────────────────────────────────
138
+ export function Select<TValue extends SelectOption['value'] = SelectOption['value']>(
139
+ props: SelectProps<TValue>,
140
+ ) {
141
+ const {
142
+ label,
143
+ options = [],
144
+ value,
145
+ onChange,
146
+ defaultValue,
147
+ size = 'small',
148
+ multiple = false,
149
+ filterable = false,
150
+ placeholder,
151
+ children,
152
+ maxHeight = 300,
153
+ maxWidth,
154
+ maxChipsToShow = 3,
155
+ renderChipLabel,
156
+ chipVariant = 'outlined',
157
+ loadOptions,
158
+ loadingMessage = 'Cargando opciones...',
159
+ noOptionsMessage = 'No hay opciones',
160
+ debounceTimeout = 300,
161
+ disabled = false,
162
+ error = false,
163
+ helperText,
164
+ borderRadius = 10,
165
+ labelPosition = 'outside',
166
+ preset,
167
+ sx,
168
+ className,
169
+ ...rest
170
+ } = props;
171
+
172
+ const theme = useTheme();
173
+ const presetSx = resolvePreset('Select', preset, theme);
174
+
175
+ const isRHFMode = 'control' in rest && (rest as any).control !== undefined;
176
+
177
+ // Custom render opcional vía <Option>{item => ...}</Option>
178
+ const customRender: RenderOptionItem | null = useMemo(() => {
179
+ if (React.Children.count(children) === 1) {
180
+ const child = React.Children.only(children);
181
+ if (isValidElement(child) && (child.type as any)?.displayName === 'Option') {
182
+ return (child.props as OptionProps).children;
183
+ }
184
+ }
185
+ return null;
186
+ }, [children]);
187
+
188
+ // Búsqueda / async
189
+ const [search, setSearch] = useState('');
190
+ const [asyncOptions, setAsyncOptions] = useState<SelectOption[]>([]);
191
+ const [loading, setLoading] = useState(false);
192
+ const debounceTimerRef = useRef<number | null>(null);
193
+ const searchInputRef = useRef<HTMLInputElement>(null);
194
+
195
+ // Focus/open tracking para el comportamiento del label "outside".
196
+ const [isOpen, setIsOpen] = useState(false);
197
+ const [isFocused, setIsFocused] = useState(false);
198
+
199
+ const isAsync = typeof loadOptions === 'function';
200
+ const currentOptions = isAsync ? asyncOptions : options;
201
+
202
+ useEffect(() => {
203
+ if (!isAsync) return;
204
+
205
+ if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
206
+
207
+ if (search) {
208
+ setLoading(true);
209
+ debounceTimerRef.current = window.setTimeout(() => {
210
+ loadOptions!(search)
211
+ .then(setAsyncOptions)
212
+ .finally(() => {
213
+ setLoading(false);
214
+ searchInputRef.current?.focus();
215
+ });
216
+ }, debounceTimeout);
217
+ } else {
218
+ setAsyncOptions([]);
219
+ setLoading(false);
220
+ searchInputRef.current?.focus();
221
+ }
222
+ }, [search, isAsync, loadOptions, debounceTimeout]);
223
+
224
+ const filteredOptions = useMemo(() => {
225
+ if (isAsync) return currentOptions;
226
+ if (!filterable) return currentOptions;
227
+ return filterOptionsByLabel(currentOptions, search);
228
+ }, [search, filterable, currentOptions, isAsync]);
229
+
230
+ const groupedOptions = useMemo(() => groupOptions(filteredOptions), [filteredOptions]);
231
+
232
+ // Items del menu (search header + loading/empty + grupos + opciones)
233
+ const menuItems = useMemo(() => {
234
+ const items: React.ReactNode[] = [];
235
+
236
+ if (filterable || isAsync) {
237
+ items.push(
238
+ <SelectSearchHeader
239
+ key="search-header"
240
+ value={search}
241
+ onChange={setSearch}
242
+ inputRef={searchInputRef}
243
+ />,
244
+ );
245
+ }
246
+
247
+ const noOptions = isGroupedOptionsEmpty(groupedOptions);
248
+
249
+ if (loading) {
250
+ items.push(
251
+ <MenuItem key="loading-message" disabled>
252
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
253
+ <CircularProgress size={20} />
254
+ <Typography>{loadingMessage}</Typography>
255
+ </Box>
256
+ </MenuItem>,
257
+ );
258
+ } else if (noOptions) {
259
+ items.push(
260
+ <MenuItem key="no-options-message" disabled>
261
+ <Typography>{noOptionsMessage}</Typography>
262
+ </MenuItem>,
263
+ );
264
+ } else {
265
+ Object.entries(groupedOptions).forEach(([group, opts]) => {
266
+ if (group !== '__default') {
267
+ items.push(
268
+ <ListSubheader key={group} disableSticky>
269
+ {group}
270
+ </ListSubheader>,
271
+ );
272
+ }
273
+ opts.forEach((opt) => {
274
+ const isSelected = multiple
275
+ ? (value as TValue[] | undefined)?.includes(opt.value as TValue) ?? false
276
+ : value === opt.value;
277
+
278
+ items.push(
279
+ renderSelectMenuItem({
280
+ option: opt,
281
+ selected: isSelected,
282
+ customRender,
283
+ }),
284
+ );
285
+ });
286
+ });
287
+ }
288
+ return items;
289
+ }, [
290
+ groupedOptions,
291
+ customRender,
292
+ filterable,
293
+ isAsync,
294
+ search,
295
+ loading,
296
+ loadingMessage,
297
+ noOptionsMessage,
298
+ multiple,
299
+ value,
300
+ ]);
301
+
302
+ // Handler de cambio
303
+ const handleChangeInternal = (
304
+ event: SelectChangeEvent<any>,
305
+ rhfOnChange?: (...event: any[]) => void,
306
+ ) => {
307
+ const newValue = event.target.value;
308
+ if (isRHFMode) {
309
+ rhfOnChange?.(newValue);
310
+ } else {
311
+ (onChange as (val: TValue | TValue[]) => void)?.(newValue);
312
+ }
313
+ };
314
+
315
+ // Sx mergeado (defaults + consumer)
316
+ const mergedSx = [
317
+ buildSelectSx(borderRadius, labelPosition),
318
+ ...(presetSx ? [presetSx] : []),
319
+ ...(Array.isArray(sx) ? sx : [sx]),
320
+ ];
321
+
322
+ // Render base
323
+ const renderSelect = (
324
+ selectValue: any,
325
+ selectOnChange: any,
326
+ onBlur?: any,
327
+ inputRef?: any,
328
+ rhfError?: boolean,
329
+ rhfHelperText?: string,
330
+ ) => {
331
+ const finalError = rhfError ?? error;
332
+ const finalHelperText = rhfHelperText ?? helperText;
333
+
334
+ const inputElement =
335
+ labelPosition === 'floating' ? (
336
+ <OutlinedInput label={label} />
337
+ ) : (
338
+ <OutlinedInput />
339
+ );
340
+
341
+ const normalizedValue = normalizeSelectValue(selectValue, multiple);
342
+ const isEmpty = isSelectValueEmpty(normalizedValue, multiple);
343
+
344
+ // Shrink cuando hay valor, foco, o dropdown abierto.
345
+ const shouldShrink = !isEmpty || isFocused || isOpen;
346
+ // Placeholder solo cuando el label ya subió.
347
+ const shouldDisplayPlaceholder = isEmpty && (isFocused || isOpen) && !!placeholder;
348
+
349
+ return (
350
+ <FormControl
351
+ fullWidth
352
+ size={size}
353
+ error={finalError}
354
+ disabled={disabled}
355
+ className={className}
356
+ sx={mergedSx}
357
+ >
358
+ {label && <InputLabel shrink={shouldShrink}>{label}</InputLabel>}
359
+ <MuiSelect
360
+ value={normalizedValue}
361
+ defaultValue={defaultValue}
362
+ multiple={multiple}
363
+ onChange={selectOnChange}
364
+ onBlur={(e) => {
365
+ setIsFocused(false);
366
+ onBlur?.(e);
367
+ }}
368
+ onFocus={() => setIsFocused(true)}
369
+ onOpen={() => setIsOpen(true)}
370
+ onClose={() => setIsOpen(false)}
371
+ renderValue={(selected) => (
372
+ <SelectValue<TValue>
373
+ selected={selected as TValue | TValue[]}
374
+ options={currentOptions}
375
+ multiple={multiple}
376
+ placeholder={placeholder}
377
+ maxChipsToShow={maxChipsToShow}
378
+ chipVariant={chipVariant}
379
+ renderChipLabel={renderChipLabel}
380
+ currentValues={(value as TValue[]) ?? []}
381
+ onDeleteChip={(next) =>
382
+ (onChange as (v: TValue | TValue[]) => void)?.(next)
383
+ }
384
+ />
385
+ )}
386
+ displayEmpty={shouldDisplayPlaceholder}
387
+ input={inputElement}
388
+ disabled={disabled}
389
+ MenuProps={{
390
+ PaperProps: {
391
+ style: { maxHeight, maxWidth },
392
+ },
393
+ }}
394
+ inputRef={inputRef}
395
+ {...(rest as any)}
396
+ >
397
+ {menuItems}
398
+ </MuiSelect>
399
+ {finalHelperText && <FormHelperText>{finalHelperText}</FormHelperText>}
400
+ </FormControl>
401
+ );
402
+ };
403
+
404
+ // Branch: RHF vs controlado
405
+ if (isRHFMode) {
406
+ const rhfProps = rest as RHFSelectProps<TValue>;
407
+ return (
408
+ <Controller
409
+ name={rhfProps.name}
410
+ control={rhfProps.control}
411
+ rules={rhfProps.validation}
412
+ render={({ field, fieldState: { error: fieldError } }) =>
413
+ renderSelect(
414
+ field.value,
415
+ (e: SelectChangeEvent<any>) => handleChangeInternal(e, field.onChange),
416
+ field.onBlur,
417
+ field.ref,
418
+ !!fieldError,
419
+ fieldError?.message,
420
+ )
421
+ }
422
+ />
423
+ );
424
+ }
425
+
426
+ return renderSelect(value, (e: SelectChangeEvent<any>) => handleChangeInternal(e));
427
+ }
428
+
429
+ export default Select;
@@ -0,0 +1,15 @@
1
+ // Re-export barrel para compatibilidad con imports antiguos.
2
+ // Los tipos ahora viven dentro de Select.tsx.
3
+ export type {
4
+ SelectOption,
5
+ LabelPosition,
6
+ ChipVariant,
7
+ SelectSize,
8
+ RenderOptionItem,
9
+ RenderChipLabel,
10
+ OptionProps,
11
+ BaseSelectProps,
12
+ RHFSelectProps,
13
+ StandardSelectProps,
14
+ SelectProps,
15
+ } from './Select';
@@ -0,0 +1,40 @@
1
+ import React from 'react';
2
+ import { ListItemIcon, MenuItem } from '@mui/material';
3
+ import CheckIcon from '@mui/icons-material/Check';
4
+
5
+ import type { SelectOption, RenderOptionItem } from '../Select';
6
+
7
+ interface RenderSelectMenuItemArgs {
8
+ option: SelectOption;
9
+ selected: boolean;
10
+ customRender?: RenderOptionItem | null;
11
+ }
12
+
13
+ /**
14
+ * Render helper (no componente) que devuelve un <MenuItem> listo para
15
+ * colocarse como hijo directo del <MuiSelect>. Se implementa como función
16
+ * y no como componente porque MUI Select inspecciona `props.value` de sus
17
+ * children para resolver la selección, y envolverlo en un componente extra
18
+ * rompe esa detección.
19
+ *
20
+ * Incluye el icono de check a la izquierda cuando `selected` es true. Si el
21
+ * consumer pasó un `<Option>{(opt) => ...}</Option>`, usa ese render para el
22
+ * contenido principal; de lo contrario muestra `option.label`.
23
+ */
24
+ export const renderSelectMenuItem = ({
25
+ option,
26
+ selected,
27
+ customRender,
28
+ }: RenderSelectMenuItemArgs): React.ReactElement => (
29
+ <MenuItem
30
+ key={String(option.value)}
31
+ value={option.value}
32
+ disabled={option.disabled}
33
+ selected={selected}
34
+ >
35
+ <ListItemIcon sx={{ minWidth: 32 }}>
36
+ {selected && <CheckIcon color="success" fontSize="small" />}
37
+ </ListItemIcon>
38
+ {customRender ? customRender(option) : option.label}
39
+ </MenuItem>
40
+ );
@@ -0,0 +1,51 @@
1
+ import React from 'react';
2
+ import { ListSubheader, TextField } from '@mui/material';
3
+
4
+ interface SelectSearchHeaderProps {
5
+ value: string;
6
+ onChange: (next: string) => void;
7
+ placeholder?: string;
8
+ inputRef?: React.Ref<HTMLInputElement>;
9
+ }
10
+
11
+ /**
12
+ * Header sticky del menu del Select que contiene el input de búsqueda.
13
+ * Se usa cuando `filterable` es true o cuando el Select opera en modo async.
14
+ * Detiene la propagación de click/keydown para que el menu no se cierre ni
15
+ * intercepte las teclas al tipear.
16
+ */
17
+ export const SelectSearchHeader: React.FC<SelectSearchHeaderProps> = ({
18
+ value,
19
+ onChange,
20
+ placeholder = 'Buscar...',
21
+ inputRef,
22
+ }) => {
23
+ return (
24
+ <ListSubheader
25
+ sx={{
26
+ px: 2,
27
+ pt: 1,
28
+ pb: 1,
29
+ backgroundColor: 'background.paper',
30
+ zIndex: 1,
31
+ position: 'sticky',
32
+ top: 0,
33
+ }}
34
+ >
35
+ <TextField
36
+ inputRef={inputRef}
37
+ placeholder={placeholder}
38
+ variant="standard"
39
+ size="small"
40
+ fullWidth
41
+ value={value}
42
+ onChange={(e) => onChange(e.target.value)}
43
+ slotProps={{ htmlInput: { autoFocus: true } }}
44
+ onClick={(e) => e.stopPropagation()}
45
+ onKeyDown={(e) => e.stopPropagation()}
46
+ />
47
+ </ListSubheader>
48
+ );
49
+ };
50
+
51
+ export default SelectSearchHeader;
@@ -0,0 +1,96 @@
1
+ import React from 'react';
2
+ import { Avatar, Box, Chip, Typography } from '@mui/material';
3
+ import ClearIcon from '@mui/icons-material/Clear';
4
+
5
+ import type {
6
+ SelectOption,
7
+ RenderChipLabel,
8
+ ChipVariant,
9
+ } from '../Select';
10
+
11
+ interface SelectValueProps<TValue extends SelectOption['value']> {
12
+ selected: TValue | TValue[] | null | undefined;
13
+ options: SelectOption[];
14
+ multiple: boolean;
15
+ placeholder?: string;
16
+ maxChipsToShow: number;
17
+ chipVariant: ChipVariant;
18
+ renderChipLabel?: RenderChipLabel;
19
+ /** Solo relevante en modo multiple. Recibe el nuevo array de valores. */
20
+ onDeleteChip?: (nextValues: TValue[]) => void;
21
+ /** Array de valores actuales (para componer el nuevo array al borrar chips). */
22
+ currentValues?: TValue[];
23
+ }
24
+
25
+ /**
26
+ * Render del valor seleccionado del Select.
27
+ * - single: texto (label) o lo que devuelva `renderChipLabel`.
28
+ * - multiple: chips con delete. Trunca después de `maxChipsToShow` mostrando "+N más".
29
+ * - empty: placeholder en color disabled.
30
+ */
31
+ export function SelectValue<TValue extends SelectOption['value']>(
32
+ props: SelectValueProps<TValue>,
33
+ ) {
34
+ const {
35
+ selected,
36
+ options,
37
+ multiple,
38
+ placeholder,
39
+ maxChipsToShow,
40
+ chipVariant,
41
+ renderChipLabel,
42
+ onDeleteChip,
43
+ currentValues,
44
+ } = props;
45
+
46
+ const isEmpty =
47
+ !selected || (Array.isArray(selected) && selected.length === 0);
48
+
49
+ if (isEmpty) {
50
+ return (
51
+ <Typography sx={{ color: 'text.disabled' }}>{placeholder || ''}</Typography>
52
+ );
53
+ }
54
+
55
+ if (!multiple) {
56
+ const item = options.find((opt) => opt.value === selected);
57
+ if (renderChipLabel && item) return <>{renderChipLabel(item)}</>;
58
+ return <>{item?.label ?? String(selected)}</>;
59
+ }
60
+
61
+ const selectedValuesArray = selected as TValue[];
62
+ const displayedChips = selectedValuesArray.slice(0, maxChipsToShow);
63
+ const hiddenChipsCount = selectedValuesArray.length - maxChipsToShow;
64
+
65
+ const handleDelete = (chipValue: TValue) => {
66
+ if (!onDeleteChip || !currentValues) return;
67
+ onDeleteChip(currentValues.filter((v) => v !== chipValue));
68
+ };
69
+
70
+ return (
71
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
72
+ {displayedChips.map((val) => {
73
+ const item = options.find((o) => o.value === val);
74
+ if (!item) return null;
75
+
76
+ return (
77
+ <Chip
78
+ variant={chipVariant}
79
+ color="primary"
80
+ size="small"
81
+ key={String(val)}
82
+ label={renderChipLabel ? renderChipLabel(item) : item.label}
83
+ avatar={item.img ? <Avatar src={item.img} /> : undefined}
84
+ onDelete={() => handleDelete(val)}
85
+ deleteIcon={<ClearIcon fontSize="small" />}
86
+ />
87
+ );
88
+ })}
89
+ {hiddenChipsCount > 0 && (
90
+ <Chip size="small" variant={chipVariant} label={`+${hiddenChipsCount} más`} />
91
+ )}
92
+ </Box>
93
+ );
94
+ }
95
+
96
+ export default SelectValue;
@@ -0,0 +1,14 @@
1
+ export { Select, Option, default } from './Select';
2
+ export type {
3
+ SelectProps,
4
+ SelectOption,
5
+ OptionProps,
6
+ BaseSelectProps,
7
+ RHFSelectProps,
8
+ StandardSelectProps,
9
+ RenderOptionItem,
10
+ RenderChipLabel,
11
+ LabelPosition,
12
+ ChipVariant,
13
+ SelectSize,
14
+ } from './Select.types';