@alepha/ui 0.10.7 → 0.11.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.
@@ -6,13 +6,93 @@ import {
6
6
  useRouter,
7
7
  } from "@alepha/react";
8
8
  import { type FormModel, useFormState } from "@alepha/react-form";
9
- import { Button, type ButtonProps, Flex } from "@mantine/core";
9
+ import {
10
+ Button,
11
+ type ButtonProps,
12
+ Flex,
13
+ Menu,
14
+ Tooltip,
15
+ type TooltipProps,
16
+ } from "@mantine/core";
17
+ import { IconChevronRight } from "@tabler/icons-react";
10
18
  import { type ReactNode, useState } from "react";
11
19
 
20
+ export interface ActionMenuItem {
21
+ /**
22
+ * Menu item type
23
+ */
24
+ type?: "item" | "divider" | "label";
25
+
26
+ /**
27
+ * Label text for the menu item
28
+ */
29
+ label?: string;
30
+
31
+ /**
32
+ * Icon element to display before the label
33
+ */
34
+ icon?: ReactNode;
35
+
36
+ /**
37
+ * Click handler for menu items
38
+ */
39
+ onClick?: () => void;
40
+
41
+ /**
42
+ * Color for the menu item (e.g., "red" for danger actions)
43
+ */
44
+ color?: string;
45
+
46
+ /**
47
+ * Nested submenu items
48
+ */
49
+ children?: ActionMenuItem[];
50
+ }
51
+
52
+ export interface ActionMenuConfig {
53
+ /**
54
+ * Array of menu items to display
55
+ */
56
+ items: ActionMenuItem[];
57
+
58
+ /**
59
+ * Menu position relative to the button
60
+ */
61
+ position?:
62
+ | "bottom"
63
+ | "bottom-start"
64
+ | "bottom-end"
65
+ | "top"
66
+ | "top-start"
67
+ | "top-end"
68
+ | "left"
69
+ | "right";
70
+
71
+ /**
72
+ * Menu width
73
+ */
74
+ width?: number | string;
75
+
76
+ /**
77
+ * Menu shadow
78
+ */
79
+ shadow?: "xs" | "sm" | "md" | "lg" | "xl";
80
+ }
81
+
12
82
  export interface ActionCommonProps extends ButtonProps {
13
83
  children?: ReactNode;
14
84
  textVisibleFrom?: "xs" | "sm" | "md" | "lg" | "xl";
15
- // TODO
85
+
86
+ /**
87
+ * Tooltip to display on hover. Can be a string for simple tooltips
88
+ * or a TooltipProps object for advanced configuration.
89
+ */
90
+ tooltip?: string | TooltipProps;
91
+
92
+ /**
93
+ * Menu configuration. When provided, the action will display a dropdown menu.
94
+ */
95
+ menu?: ActionMenuConfig;
16
96
 
17
97
  /**
18
98
  * If set, a confirmation dialog will be shown before performing the action.
@@ -28,25 +108,78 @@ export type ActionProps = ActionCommonProps &
28
108
 
29
109
  // ---------------------------------------------------------------------------------------------------------------------
30
110
 
111
+ // Helper function to render menu items recursively
112
+ const renderMenuItem = (item: ActionMenuItem, index: number): ReactNode => {
113
+ // Render divider
114
+ if (item.type === "divider") {
115
+ return <Menu.Divider key={index} />;
116
+ }
117
+
118
+ // Render label
119
+ if (item.type === "label") {
120
+ return <Menu.Label key={index}>{item.label}</Menu.Label>;
121
+ }
122
+
123
+ // Render submenu if has children
124
+ if (item.children && item.children.length > 0) {
125
+ return (
126
+ <Menu key={index} trigger="hover" position="right-start" offset={2}>
127
+ <Menu.Target>
128
+ <Menu.Item
129
+ leftSection={item.icon}
130
+ rightSection={<IconChevronRight size={14} />}
131
+ >
132
+ {item.label}
133
+ </Menu.Item>
134
+ </Menu.Target>
135
+ <Menu.Dropdown>
136
+ {item.children.map((child, childIndex) =>
137
+ renderMenuItem(child, childIndex),
138
+ )}
139
+ </Menu.Dropdown>
140
+ </Menu>
141
+ );
142
+ }
143
+
144
+ // Render regular menu item
145
+ return (
146
+ <Menu.Item
147
+ key={index}
148
+ leftSection={item.icon}
149
+ onClick={item.onClick}
150
+ color={item.color}
151
+ >
152
+ {item.label}
153
+ </Menu.Item>
154
+ );
155
+ };
156
+
31
157
  const Action = (_props: ActionProps) => {
32
158
  const props = { variant: "subtle", ..._props };
159
+ const { tooltip, menu, ...restProps } = props;
33
160
 
34
161
  if (props.leftSection && !props.children) {
35
- props.className ??= "mantine-Action-iconOnly";
36
- props.p ??= "xs";
162
+ restProps.className ??= "mantine-Action-iconOnly";
163
+ restProps.p ??= "xs";
37
164
  }
38
165
 
39
166
  if (props.textVisibleFrom) {
40
- const { children, textVisibleFrom, leftSection, ...rest } = props;
167
+ const { children, textVisibleFrom, leftSection, ...rest } = restProps;
41
168
  return (
42
169
  <>
43
170
  <Flex w={"100%"} visibleFrom={textVisibleFrom}>
44
- <Action flex={1} {...rest} leftSection={leftSection}>
171
+ <Action
172
+ flex={1}
173
+ {...rest}
174
+ leftSection={leftSection}
175
+ tooltip={tooltip}
176
+ menu={menu}
177
+ >
45
178
  {children}
46
179
  </Action>
47
180
  </Flex>
48
181
  <Flex w={"100%"} hiddenFrom={textVisibleFrom}>
49
- <Action px={"xs"} {...rest}>
182
+ <Action px={"xs"} {...rest} tooltip={tooltip} menu={menu}>
50
183
  {leftSection}
51
184
  </Action>
52
185
  </Flex>
@@ -55,34 +188,62 @@ const Action = (_props: ActionProps) => {
55
188
  }
56
189
 
57
190
  const renderAction = () => {
58
- if ("href" in props && props.href) {
191
+ if ("href" in restProps && restProps.href) {
59
192
  return (
60
- <ActionHref {...props} href={props.href}>
61
- {props.children}
193
+ <ActionHref {...restProps} href={restProps.href}>
194
+ {restProps.children}
62
195
  </ActionHref>
63
196
  );
64
197
  }
65
198
 
66
- if ("onClick" in props && props.onClick) {
199
+ if ("onClick" in restProps && restProps.onClick) {
67
200
  return (
68
- <ActionClick {...props} onClick={props.onClick}>
69
- {props.children}
201
+ <ActionClick {...restProps} onClick={restProps.onClick}>
202
+ {restProps.children}
70
203
  </ActionClick>
71
204
  );
72
205
  }
73
206
 
74
- if ("form" in props && props.form) {
207
+ if ("form" in restProps && restProps.form) {
75
208
  return (
76
- <ActionSubmit {...props} form={props.form}>
77
- {props.children}
209
+ <ActionSubmit {...restProps} form={restProps.form}>
210
+ {restProps.children}
78
211
  </ActionSubmit>
79
212
  );
80
213
  }
81
214
 
82
- return <Button {...(props as any)}>{props.children}</Button>;
215
+ return <Button {...(restProps as any)}>{restProps.children}</Button>;
83
216
  };
84
217
 
85
- return renderAction();
218
+ let actionElement = renderAction();
219
+
220
+ // Wrap with Menu if provided
221
+ if (menu) {
222
+ actionElement = (
223
+ <Menu
224
+ position={menu.position || "bottom-start"}
225
+ width={menu.width || 200}
226
+ shadow={menu.shadow || "md"}
227
+ >
228
+ <Menu.Target>{actionElement}</Menu.Target>
229
+ <Menu.Dropdown>
230
+ {menu.items.map((item, index) => renderMenuItem(item, index))}
231
+ </Menu.Dropdown>
232
+ </Menu>
233
+ );
234
+ }
235
+
236
+ // Wrap with Tooltip if provided
237
+ if (tooltip) {
238
+ const tooltipProps: TooltipProps =
239
+ typeof tooltip === "string"
240
+ ? { label: tooltip, children: actionElement }
241
+ : { ...tooltip, children: actionElement };
242
+
243
+ return <Tooltip {...tooltipProps} />;
244
+ }
245
+
246
+ return actionElement;
86
247
  };
87
248
 
88
249
  export default Action;
@@ -1,12 +1,5 @@
1
- import { type TObject, TypeBoxError } from "@alepha/core";
1
+ import { useFormState } from "@alepha/react-form";
2
2
  import {
3
- type InputField,
4
- type UseFormStateReturn,
5
- useFormState,
6
- } from "@alepha/react-form";
7
- import {
8
- Autocomplete,
9
- type AutocompleteProps,
10
3
  ColorInput,
11
4
  type ColorInputProps,
12
5
  FileInput,
@@ -17,9 +10,6 @@ import {
17
10
  type NumberInputProps,
18
11
  PasswordInput,
19
12
  type PasswordInputProps,
20
- SegmentedControl,
21
- type SegmentedControlProps,
22
- type SelectProps,
23
13
  Switch,
24
14
  type SwitchProps,
25
15
  Textarea,
@@ -32,20 +22,17 @@ import type {
32
22
  DateTimePickerProps,
33
23
  TimeInputProps,
34
24
  } from "@mantine/dates";
35
- import type { ComponentType, ReactNode } from "react";
36
- import { getDefaultIcon } from "../utils/icons.tsx";
37
- import { prettyName } from "../utils/string.ts";
25
+ import type { ComponentType } from "react";
26
+ import { type GenericControlProps, parseInput } from "../utils/parseInput.ts";
38
27
  import ControlDate from "./ControlDate";
39
- import ControlSelect from "./ControlSelect";
28
+ import ControlSelect, { type ControlSelectProps } from "./ControlSelect";
40
29
 
41
30
  export interface ControlProps extends GenericControlProps {
42
31
  text?: TextInputProps;
43
32
  area?: boolean | TextareaProps;
44
- select?: boolean | SelectProps;
45
- autocomplete?: boolean | AutocompleteProps;
33
+ select?: boolean | Partial<ControlSelectProps>;
46
34
  password?: boolean | PasswordInputProps;
47
35
  switch?: boolean | SwitchProps;
48
- segmented?: boolean | Partial<SegmentedControlProps>;
49
36
  number?: boolean | NumberInputProps;
50
37
  file?: boolean | FileInputProps;
51
38
  color?: boolean | ColorInputProps;
@@ -76,20 +63,17 @@ export interface ControlProps extends GenericControlProps {
76
63
  *
77
64
  * Automatically handles labels, descriptions, error messages, required state, and default icons.
78
65
  */
79
- const Control = (props: ControlProps) => {
80
- const form = useFormState(props.input);
81
- const { inputProps, id, icon } = parseInput(props, form);
82
- if (!props.input?.props) {
66
+ const Control = (_props: ControlProps) => {
67
+ const form = useFormState(_props.input, ["error"]);
68
+ const { inputProps, id, icon, format, schema } = parseInput(_props, form);
69
+ if (!_props.input?.props) {
83
70
  return null;
84
71
  }
85
72
 
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;
73
+ const props = {
74
+ ..._props,
75
+ ...schema.$control,
76
+ };
93
77
 
94
78
  //region <Custom/>
95
79
  if (props.custom) {
@@ -164,55 +148,6 @@ const Control = (props: ControlProps) => {
164
148
  }
165
149
  //endregion
166
150
 
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
198
-
199
- //region <Autocomplete/>
200
- if (props.autocomplete) {
201
- const autocompleteProps =
202
- typeof props.autocomplete === "object" ? props.autocomplete : {};
203
-
204
- return (
205
- <Autocomplete
206
- {...inputProps}
207
- id={id}
208
- leftSection={icon}
209
- {...props.input.props}
210
- {...autocompleteProps}
211
- />
212
- );
213
- }
214
- //endregion
215
-
216
151
  //region <ControlSelect/>
217
152
  // Handle: single enum, array of enum, array of strings, or explicit select/multi/tags props
218
153
  const isEnum =
@@ -225,13 +160,14 @@ const Control = (props: ControlProps) => {
225
160
  props.input.schema.type === "array";
226
161
 
227
162
  if (isEnum || isArray || props.select) {
163
+ const opts = typeof props.select === "object" ? props.select : {};
228
164
  return (
229
165
  <ControlSelect
230
166
  input={props.input}
231
167
  title={props.title}
232
168
  description={props.description}
233
169
  icon={icon}
234
- select={props.select}
170
+ {...opts}
235
171
  />
236
172
  );
237
173
  }
@@ -348,81 +284,6 @@ const Control = (props: ControlProps) => {
348
284
 
349
285
  export default Control;
350
286
 
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;
412
-
413
- return {
414
- id,
415
- icon,
416
- inputProps: {
417
- label,
418
- description,
419
- error,
420
- required,
421
- disabled,
422
- },
423
- };
424
- };
425
-
426
287
  export type CustomControlProps = {
427
288
  defaultValue: any;
428
289
  onChange: (value: any) => void;
@@ -7,7 +7,7 @@ import {
7
7
  TimeInput,
8
8
  type TimeInputProps,
9
9
  } from "@mantine/dates";
10
- import { type GenericControlProps, parseInput } from "./Control.tsx";
10
+ import { type GenericControlProps, parseInput } from "../utils/parseInput.ts";
11
11
 
12
12
  export interface ControlDateProps extends GenericControlProps {
13
13
  date?: boolean | DateInputProps;
@@ -27,19 +27,11 @@ export interface ControlDateProps extends GenericControlProps {
27
27
  */
28
28
  const ControlDate = (props: ControlDateProps) => {
29
29
  const form = useFormState(props.input);
30
- const { inputProps, id, icon } = parseInput(props, form);
30
+ const { inputProps, id, icon, format } = parseInput(props, form);
31
31
  if (!props.input?.props) {
32
32
  return null;
33
33
  }
34
34
 
35
- // Detect format from schema
36
- const format =
37
- props.input.schema &&
38
- "format" in props.input.schema &&
39
- typeof props.input.schema.format === "string"
40
- ? props.input.schema.format
41
- : undefined;
42
-
43
35
  // region <DateTimePicker/>
44
36
  if (props.datetime || format === "date-time") {
45
37
  const dateTimePickerProps =
@@ -1,18 +1,33 @@
1
1
  import { useFormState } from "@alepha/react-form";
2
2
  import {
3
+ Autocomplete,
4
+ type AutocompleteProps,
5
+ Flex,
6
+ Input,
3
7
  MultiSelect,
4
8
  type MultiSelectProps,
9
+ SegmentedControl,
10
+ type SegmentedControlProps,
5
11
  Select,
6
12
  type SelectProps,
7
13
  TagsInput,
8
14
  type TagsInputProps,
9
15
  } from "@mantine/core";
10
- import { type GenericControlProps, parseInput } from "./Control.tsx";
16
+ import { useEffect, useState } from "react";
17
+ import { type GenericControlProps, parseInput } from "../utils/parseInput.ts";
18
+
19
+ export type SelectValueLabel =
20
+ | string
21
+ | { value: string; label: string; icon?: string };
11
22
 
12
23
  export interface ControlSelectProps extends GenericControlProps {
13
24
  select?: boolean | SelectProps;
14
25
  multi?: boolean | MultiSelectProps;
15
26
  tags?: boolean | TagsInputProps;
27
+ autocomplete?: boolean | AutocompleteProps;
28
+ segmented?: boolean | Partial<SegmentedControlProps>;
29
+
30
+ loader?: () => Promise<SelectValueLabel[]>;
16
31
  }
17
32
 
18
33
  /**
@@ -31,9 +46,6 @@ export interface ControlSelectProps extends GenericControlProps {
31
46
  const ControlSelect = (props: ControlSelectProps) => {
32
47
  const form = useFormState(props.input);
33
48
  const { inputProps, id, icon } = parseInput(props, form);
34
- if (!props.input?.props) {
35
- return null;
36
- }
37
49
 
38
50
  // Detect if schema is an array type
39
51
  const isArray =
@@ -58,6 +70,61 @@ const ControlSelect = (props: ControlSelectProps) => {
58
70
  ? props.input.schema.enum
59
71
  : [];
60
72
 
73
+ const [data, setData] = useState<SelectValueLabel[]>([]);
74
+
75
+ useEffect(() => {
76
+ if (!props.input?.props) {
77
+ return;
78
+ }
79
+
80
+ if (props.loader) {
81
+ props.loader().then(setData);
82
+ } else {
83
+ setData(enumValues);
84
+ }
85
+ }, [props.input, props.loader]);
86
+
87
+ if (!props.input?.props) {
88
+ return null;
89
+ }
90
+
91
+ if (props.segmented) {
92
+ const segmentedControlProps: Partial<SegmentedControlProps> =
93
+ typeof props.segmented === "object" ? props.segmented : {};
94
+
95
+ return (
96
+ <Input.Wrapper {...inputProps}>
97
+ <Flex mt={"calc(var(--mantine-spacing-xs) / 2)"}>
98
+ <SegmentedControl
99
+ disabled={inputProps.disabled}
100
+ defaultValue={String(props.input.props.defaultValue)}
101
+ {...segmentedControlProps}
102
+ onChange={(value) => {
103
+ props.input.set(value);
104
+ }}
105
+ data={data.slice(0, 10)}
106
+ />
107
+ </Flex>
108
+ </Input.Wrapper>
109
+ );
110
+ }
111
+
112
+ if (props.autocomplete) {
113
+ const autocompleteProps =
114
+ typeof props.autocomplete === "object" ? props.autocomplete : {};
115
+
116
+ return (
117
+ <Autocomplete
118
+ {...inputProps}
119
+ id={id}
120
+ leftSection={icon}
121
+ data={data}
122
+ {...props.input.props}
123
+ {...autocompleteProps}
124
+ />
125
+ );
126
+ }
127
+
61
128
  // region <TagsInput/> - for array of strings without enum
62
129
  if ((isArray && !itemsEnum) || props.tags) {
63
130
  const tagsInputProps = typeof props.tags === "object" ? props.tags : {};
@@ -111,11 +178,6 @@ const ControlSelect = (props: ControlSelectProps) => {
111
178
  // endregion
112
179
 
113
180
  // region <Select/> - for single enum value
114
- const data = enumValues.map((value: string) => ({
115
- value,
116
- label: value,
117
- }));
118
-
119
181
  const selectProps = typeof props.select === "object" ? props.select : {};
120
182
 
121
183
  return (
@@ -24,10 +24,11 @@ const DarkModeButton = (props: DarkModeButtonProps) => {
24
24
  const { setColorScheme } = useMantineColorScheme();
25
25
  const computedColorScheme = useComputedColorScheme("light");
26
26
  const [colorScheme, setColorScheme2] = useState("default");
27
+ const mode = props.mode ?? "minimal";
28
+
27
29
  useEffect(() => {
28
30
  setColorScheme2(computedColorScheme);
29
31
  }, [computedColorScheme]);
30
- const mode = props.mode ?? "minimal";
31
32
 
32
33
  const toggleColorScheme = () => {
33
34
  setColorScheme(computedColorScheme === "dark" ? "light" : "dark");
@@ -67,7 +68,13 @@ const DarkModeButton = (props: DarkModeButtonProps) => {
67
68
  size={props.size ?? "lg"}
68
69
  aria-label="Toggle color scheme"
69
70
  >
70
- {colorScheme === "dark" ? <IconSun size={20} /> : <IconMoon size={20} />}
71
+ {colorScheme === "dark" ? (
72
+ <IconSun size={20} />
73
+ ) : colorScheme === "light" ? (
74
+ <IconMoon size={20} />
75
+ ) : (
76
+ <Flex h={20} />
77
+ )}
71
78
  </ActionIcon>
72
79
  );
73
80
  };