@alepha/ui 0.10.5 → 0.10.7

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.
@@ -1,275 +1,429 @@
1
- import { TypeBoxError } from "@alepha/core";
2
- import { type InputField, useFormState } from "@alepha/react-form";
1
+ import { type TObject, TypeBoxError } from "@alepha/core";
3
2
  import {
4
- Autocomplete,
5
- type AutocompleteProps,
6
- Flex,
7
- Input,
8
- PasswordInput,
9
- type PasswordInputProps,
10
- SegmentedControl,
11
- type SegmentedControlProps,
12
- Select,
13
- type SelectProps,
14
- Switch,
15
- type SwitchProps,
16
- Textarea,
17
- type TextareaProps,
18
- TextInput,
19
- type TextInputProps,
3
+ type InputField,
4
+ type UseFormStateReturn,
5
+ useFormState,
6
+ } from "@alepha/react-form";
7
+ import {
8
+ Autocomplete,
9
+ type AutocompleteProps,
10
+ ColorInput,
11
+ type ColorInputProps,
12
+ FileInput,
13
+ type FileInputProps,
14
+ Flex,
15
+ Input,
16
+ NumberInput,
17
+ type NumberInputProps,
18
+ PasswordInput,
19
+ type PasswordInputProps,
20
+ SegmentedControl,
21
+ type SegmentedControlProps,
22
+ type SelectProps,
23
+ Switch,
24
+ type SwitchProps,
25
+ Textarea,
26
+ type TextareaProps,
27
+ TextInput,
28
+ type TextInputProps,
20
29
  } from "@mantine/core";
30
+ import type {
31
+ DateInputProps,
32
+ DateTimePickerProps,
33
+ TimeInputProps,
34
+ } from "@mantine/dates";
21
35
  import type { ComponentType, ReactNode } from "react";
36
+ import { getDefaultIcon } from "../utils/icons.tsx";
37
+ import { prettyName } from "../utils/string.ts";
38
+ import ControlDate from "./ControlDate";
39
+ import ControlSelect from "./ControlSelect";
22
40
 
23
- export interface ControlProps {
24
- input: InputField;
25
-
26
- title?: string;
27
- description?: string;
28
-
29
- icon?: ReactNode;
30
-
31
- text?: TextInputProps;
32
- area?: boolean | TextareaProps;
33
- select?: boolean | SelectProps;
34
- autocomplete?: boolean | AutocompleteProps;
35
- password?: boolean | PasswordInputProps;
36
- switch?: boolean | SwitchProps;
37
- segmented?: boolean | Partial<SegmentedControlProps>;
38
-
39
- custom?: ComponentType<CustomControlProps>;
41
+ export interface ControlProps extends GenericControlProps {
42
+ text?: TextInputProps;
43
+ area?: boolean | TextareaProps;
44
+ select?: boolean | SelectProps;
45
+ autocomplete?: boolean | AutocompleteProps;
46
+ password?: boolean | PasswordInputProps;
47
+ switch?: boolean | SwitchProps;
48
+ segmented?: boolean | Partial<SegmentedControlProps>;
49
+ number?: boolean | NumberInputProps;
50
+ file?: boolean | FileInputProps;
51
+ color?: boolean | ColorInputProps;
52
+ date?: boolean | DateInputProps;
53
+ datetime?: boolean | DateTimePickerProps;
54
+ time?: boolean | TimeInputProps;
55
+ custom?: ComponentType<CustomControlProps>;
40
56
  }
41
57
 
42
58
  /**
43
59
  * Generic form control that renders the appropriate input based on the schema and props.
44
60
  *
45
61
  * Supports:
46
- * - TextInput
62
+ * - TextInput (with format detection: email, url, tel)
47
63
  * - Textarea
64
+ * - NumberInput (for number/integer types)
65
+ * - FileInput
66
+ * - ColorInput (for color format)
48
67
  * - Select (for enum types)
49
68
  * - Autocomplete
50
69
  * - PasswordInput
51
70
  * - Switch (for boolean types)
52
71
  * - SegmentedControl (for enum types)
72
+ * - DateInput (for date format)
73
+ * - DateTimePicker (for date-time format)
74
+ * - TimeInput (for time format)
53
75
  * - Custom component
54
76
  *
55
- * Automatically handles labels, descriptions, error messages, and required state.
77
+ * Automatically handles labels, descriptions, error messages, required state, and default icons.
56
78
  */
57
79
  const Control = (props: ControlProps) => {
58
- const form = useFormState(props.input);
59
- if (!props.input?.props) {
60
- return null;
61
- }
80
+ const form = useFormState(props.input);
81
+ const { inputProps, id, icon } = parseInput(props, form);
82
+ if (!props.input?.props) {
83
+ return null;
84
+ }
85
+
86
+ // Extract format once to avoid redeclaration
87
+ const format =
88
+ props.input.schema &&
89
+ "format" in props.input.schema &&
90
+ typeof props.input.schema.format === "string"
91
+ ? props.input.schema.format
92
+ : undefined;
62
93
 
63
- // shared props
94
+ //region <Custom/>
95
+ if (props.custom) {
96
+ const Custom = props.custom;
97
+ return (
98
+ <Input.Wrapper {...inputProps}>
99
+ <Flex flex={1} mt={"calc(var(--mantine-spacing-xs) / 2)"}>
100
+ <Custom
101
+ defaultValue={props.input.props.defaultValue}
102
+ onChange={(value) => {
103
+ props.input.set(value);
104
+ }}
105
+ />
106
+ </Flex>
107
+ </Input.Wrapper>
108
+ );
109
+ }
110
+ //endregion
64
111
 
65
- const disabled = false; // form.loading;
66
- const id = props.input.props.id;
67
- const label =
68
- props.title ??
69
- ("title" in props.input.schema &&
70
- typeof props.input.schema.title === "string"
71
- ? props.input.schema.title
72
- : undefined) ??
73
- prettyName(props.input.path);
74
- const description =
75
- props.description ??
76
- ("description" in props.input.schema &&
77
- typeof props.input.schema.description === "string"
78
- ? props.input.schema.description
79
- : undefined);
80
- const error =
81
- form.error && form.error instanceof TypeBoxError
82
- ? form.error.value.message
83
- : undefined;
84
- const icon = props.icon;
85
- const required = props.input.required;
112
+ //region <NumberInput/>
113
+ if (
114
+ props.number ||
115
+ (props.input.schema &&
116
+ "type" in props.input.schema &&
117
+ (props.input.schema.type === "number" ||
118
+ props.input.schema.type === "integer"))
119
+ ) {
120
+ const numberInputProps =
121
+ typeof props.number === "object" ? props.number : {};
122
+ const { type, ...inputPropsWithoutType } = props.input.props;
123
+ return (
124
+ <NumberInput
125
+ {...inputProps}
126
+ id={id}
127
+ leftSection={icon}
128
+ {...inputPropsWithoutType}
129
+ {...numberInputProps}
130
+ />
131
+ );
132
+ }
133
+ //endregion
86
134
 
87
- const inputProps = {
88
- label,
89
- description,
90
- error,
91
- required,
92
- disabled,
93
- };
135
+ //region <FileInput/>
136
+ if (props.file) {
137
+ const fileInputProps = typeof props.file === "object" ? props.file : {};
138
+ return (
139
+ <FileInput
140
+ {...inputProps}
141
+ id={id}
142
+ leftSection={icon}
143
+ onChange={(file) => {
144
+ props.input.set(file);
145
+ }}
146
+ {...fileInputProps}
147
+ />
148
+ );
149
+ }
150
+ //endregion
94
151
 
95
- // -------------------------------------------------------------------------------------------------------------------
152
+ //region <ColorInput/>
153
+ if (props.color || format === "color") {
154
+ const colorInputProps = typeof props.color === "object" ? props.color : {};
155
+ return (
156
+ <ColorInput
157
+ {...inputProps}
158
+ id={id}
159
+ leftSection={icon}
160
+ {...props.input.props}
161
+ {...colorInputProps}
162
+ />
163
+ );
164
+ }
165
+ //endregion
96
166
 
97
- if (props.custom) {
98
- const Custom = props.custom;
99
- return (
100
- <Input.Wrapper {...inputProps}>
101
- <Flex flex={1} mt={"calc(var(--mantine-spacing-xs) / 2)"}>
102
- <Custom
103
- defaultValue={props.input.props.defaultValue}
104
- onChange={(value) => {
105
- props.input.set(value);
106
- }}
107
- />
108
- </Flex>
109
- </Input.Wrapper>
110
- );
111
- }
167
+ //region <SegmentedControl/>
168
+ if (props.segmented) {
169
+ const segmentedControlProps: Partial<SegmentedControlProps> =
170
+ typeof props.segmented === "object" ? props.segmented : {};
171
+ const data =
172
+ segmentedControlProps.data ??
173
+ (props.input.schema &&
174
+ "enum" in props.input.schema &&
175
+ Array.isArray(props.input.schema.enum)
176
+ ? props.input.schema.enum?.map((value: string) => ({
177
+ value,
178
+ label: value,
179
+ }))
180
+ : []);
181
+ return (
182
+ <Input.Wrapper {...inputProps}>
183
+ <Flex mt={"calc(var(--mantine-spacing-xs) / 2)"}>
184
+ <SegmentedControl
185
+ disabled={inputProps.disabled}
186
+ defaultValue={String(props.input.props.defaultValue)}
187
+ {...segmentedControlProps}
188
+ onChange={(value) => {
189
+ props.input.set(value);
190
+ }}
191
+ data={data}
192
+ />
193
+ </Flex>
194
+ </Input.Wrapper>
195
+ );
196
+ }
197
+ //endregion
112
198
 
113
- // region <SegmentedControl/>
114
- if (props.segmented) {
115
- const segmentedControlProps: Partial<SegmentedControlProps> =
116
- typeof props.segmented === "object" ? props.segmented : {};
117
- const data =
118
- segmentedControlProps.data ??
119
- (props.input.schema &&
120
- "enum" in props.input.schema &&
121
- Array.isArray(props.input.schema.enum)
122
- ? props.input.schema.enum?.map((value: string) => ({
123
- value,
124
- label: value,
125
- }))
126
- : []);
127
- return (
128
- <Input.Wrapper {...inputProps}>
129
- <Flex mt={"calc(var(--mantine-spacing-xs) / 2)"}>
130
- <SegmentedControl
131
- disabled={disabled}
132
- defaultValue={String(props.input.props.defaultValue)}
133
- {...segmentedControlProps}
134
- onChange={(value) => {
135
- props.input.set(value);
136
- }}
137
- data={data}
138
- />
139
- </Flex>
140
- </Input.Wrapper>
141
- );
142
- }
143
- // endregion
199
+ //region <Autocomplete/>
200
+ if (props.autocomplete) {
201
+ const autocompleteProps =
202
+ typeof props.autocomplete === "object" ? props.autocomplete : {};
144
203
 
145
- // region <Autocomplete/>
146
- if (props.autocomplete) {
147
- const autocompleteProps =
148
- typeof props.autocomplete === "object" ? props.autocomplete : {};
204
+ return (
205
+ <Autocomplete
206
+ {...inputProps}
207
+ id={id}
208
+ leftSection={icon}
209
+ {...props.input.props}
210
+ {...autocompleteProps}
211
+ />
212
+ );
213
+ }
214
+ //endregion
149
215
 
150
- return (
151
- <Autocomplete
152
- {...inputProps}
153
- id={id}
154
- leftSection={icon}
155
- {...props.input.props}
156
- {...autocompleteProps}
157
- />
158
- );
159
- }
160
- // endregion
216
+ //region <ControlSelect/>
217
+ // Handle: single enum, array of enum, array of strings, or explicit select/multi/tags props
218
+ const isEnum =
219
+ props.input.schema &&
220
+ "enum" in props.input.schema &&
221
+ props.input.schema.enum;
222
+ const isArray =
223
+ props.input.schema &&
224
+ "type" in props.input.schema &&
225
+ props.input.schema.type === "array";
161
226
 
162
- // region <Select/>
163
- if (
164
- (props.input.schema &&
165
- "enum" in props.input.schema &&
166
- props.input.schema.enum) ||
167
- props.select
168
- ) {
169
- const data =
170
- props.input.schema &&
171
- "enum" in props.input.schema &&
172
- Array.isArray(props.input.schema.enum)
173
- ? props.input.schema.enum?.map((value: string) => ({
174
- value,
175
- label: value,
176
- }))
177
- : [];
227
+ if (isEnum || isArray || props.select) {
228
+ return (
229
+ <ControlSelect
230
+ input={props.input}
231
+ title={props.title}
232
+ description={props.description}
233
+ icon={icon}
234
+ select={props.select}
235
+ />
236
+ );
237
+ }
238
+ //endregion
178
239
 
179
- const selectProps = typeof props.select === "object" ? props.select : {};
240
+ //region <Switch/>
241
+ if (
242
+ (props.input.schema &&
243
+ "type" in props.input.schema &&
244
+ props.input.schema.type === "boolean") ||
245
+ props.switch
246
+ ) {
247
+ const switchProps = typeof props.switch === "object" ? props.switch : {};
180
248
 
181
- return (
182
- <Select
183
- {...inputProps}
184
- id={id}
185
- leftSection={icon}
186
- data={data}
187
- {...props.input.props}
188
- {...selectProps}
189
- />
190
- );
191
- }
192
- // endregion
249
+ return (
250
+ <Switch
251
+ {...inputProps}
252
+ id={id}
253
+ color={"blue"}
254
+ defaultChecked={props.input.props.defaultValue}
255
+ {...props.input.props}
256
+ {...switchProps}
257
+ />
258
+ );
259
+ }
260
+ //endregion
193
261
 
194
- // region <Switch/>
262
+ //region <PasswordInput/>
263
+ if (props.password || props.input.props.name?.includes("password")) {
264
+ const passwordInputProps =
265
+ typeof props.password === "object" ? props.password : {};
266
+ return (
267
+ <PasswordInput
268
+ {...inputProps}
269
+ id={id}
270
+ leftSection={icon}
271
+ {...props.input.props}
272
+ {...passwordInputProps}
273
+ />
274
+ );
275
+ }
276
+ //endregion
195
277
 
196
- if (
197
- (props.input.schema &&
198
- "type" in props.input.schema &&
199
- props.input.schema.type === "boolean") ||
200
- props.switch
201
- ) {
202
- const switchProps = typeof props.switch === "object" ? props.switch : {};
278
+ //region <Textarea/>
279
+ if (props.area) {
280
+ const textAreaProps = typeof props.area === "object" ? props.area : {};
281
+ return (
282
+ <Textarea
283
+ {...inputProps}
284
+ id={id}
285
+ leftSection={icon}
286
+ {...props.input.props}
287
+ {...textAreaProps}
288
+ />
289
+ );
290
+ }
291
+ //endregion
203
292
 
204
- return (
205
- <Switch
206
- {...inputProps}
207
- id={id}
208
- color={"blue"}
209
- defaultChecked={props.input.props.defaultValue}
210
- {...props.input.props}
211
- {...switchProps}
212
- />
213
- );
214
- }
215
- // endregion
293
+ //region <ControlDate/>
294
+ // Handle: date, date-time, and time formats
295
+ if (
296
+ props.date ||
297
+ props.datetime ||
298
+ props.time ||
299
+ format === "date" ||
300
+ format === "date-time" ||
301
+ format === "time"
302
+ ) {
303
+ return (
304
+ <ControlDate
305
+ input={props.input}
306
+ title={props.title}
307
+ description={props.description}
308
+ icon={icon}
309
+ date={props.date}
310
+ datetime={props.datetime}
311
+ time={props.time}
312
+ />
313
+ );
314
+ }
315
+ //endregion
216
316
 
217
- // region <PasswordInput/>
218
- if (props.password) {
219
- const passwordInputProps =
220
- typeof props.password === "object" ? props.password : {};
221
- return (
222
- <PasswordInput
223
- {...inputProps}
224
- id={id}
225
- leftSection={icon}
226
- {...props.input.props}
227
- {...passwordInputProps}
228
- />
229
- );
230
- }
231
- //endregion
317
+ //region <TextInput/> with format detection
318
+ const textInputProps = typeof props.text === "object" ? props.text : {};
232
319
 
233
- //region <Textarea/>
234
- if (props.area) {
235
- const textAreaProps = typeof props.area === "object" ? props.area : {};
236
- return (
237
- <Textarea
238
- {...inputProps}
239
- id={id}
240
- leftSection={icon}
241
- {...props.input.props}
242
- {...textAreaProps}
243
- />
244
- );
245
- }
246
- //endregion
320
+ // Detect HTML5 input type from format
321
+ const getInputType = (): string | undefined => {
322
+ switch (format) {
323
+ case "email":
324
+ return "email";
325
+ case "url":
326
+ case "uri":
327
+ return "url";
328
+ case "tel":
329
+ case "phone":
330
+ return "tel";
331
+ default:
332
+ return undefined;
333
+ }
334
+ };
247
335
 
248
- // region <TextInput/>
249
- const textInputProps = typeof props.text === "object" ? props.text : {};
250
- return (
251
- <TextInput
252
- {...inputProps}
253
- id={id}
254
- leftSection={icon}
255
- {...props.input.props}
256
- {...textInputProps}
257
- />
258
- );
259
- //endregion
336
+ return (
337
+ <TextInput
338
+ {...inputProps}
339
+ id={id}
340
+ leftSection={icon}
341
+ type={getInputType()}
342
+ {...props.input.props}
343
+ {...textInputProps}
344
+ />
345
+ );
346
+ //endregion
260
347
  };
261
348
 
262
349
  export default Control;
263
350
 
264
- const prettyName = (name: string) => {
265
- return capitalize(name.replaceAll("/", ""));
266
- };
351
+ // =============================================================================
352
+ // Helper Types and Functions
353
+ // =============================================================================
354
+
355
+ export interface GenericControlProps {
356
+ input: InputField;
357
+ title?: string;
358
+ description?: string;
359
+ icon?: ReactNode;
360
+ }
361
+
362
+ export const parseInput = (
363
+ props: GenericControlProps,
364
+ form: UseFormStateReturn<TObject>,
365
+ ) => {
366
+ const disabled = false; // form.loading;
367
+ const id = props.input.props.id;
368
+ const label =
369
+ props.title ??
370
+ ("title" in props.input.schema &&
371
+ typeof props.input.schema.title === "string"
372
+ ? props.input.schema.title
373
+ : undefined) ??
374
+ prettyName(props.input.path);
375
+ const description =
376
+ props.description ??
377
+ ("description" in props.input.schema &&
378
+ typeof props.input.schema.description === "string"
379
+ ? props.input.schema.description
380
+ : undefined);
381
+ const error =
382
+ form.error && form.error instanceof TypeBoxError
383
+ ? form.error.value.message
384
+ : undefined;
385
+
386
+ // Auto-generate icon if not provided
387
+ const icon =
388
+ props.icon ??
389
+ getDefaultIcon({
390
+ type:
391
+ props.input.schema && "type" in props.input.schema
392
+ ? String(props.input.schema.type)
393
+ : undefined,
394
+ format:
395
+ props.input.schema &&
396
+ "format" in props.input.schema &&
397
+ typeof props.input.schema.format === "string"
398
+ ? props.input.schema.format
399
+ : undefined,
400
+ name: props.input.props.name,
401
+ isEnum:
402
+ props.input.schema &&
403
+ "enum" in props.input.schema &&
404
+ Boolean(props.input.schema.enum),
405
+ isArray:
406
+ props.input.schema &&
407
+ "type" in props.input.schema &&
408
+ props.input.schema.type === "array",
409
+ });
410
+
411
+ const required = props.input.required;
267
412
 
268
- const capitalize = (str: string) => {
269
- return str.charAt(0).toUpperCase() + str.slice(1);
413
+ return {
414
+ id,
415
+ icon,
416
+ inputProps: {
417
+ label,
418
+ description,
419
+ error,
420
+ required,
421
+ disabled,
422
+ },
423
+ };
270
424
  };
271
425
 
272
426
  export type CustomControlProps = {
273
- defaultValue: any;
274
- onChange: (value: any) => void;
427
+ defaultValue: any;
428
+ onChange: (value: any) => void;
275
429
  };