@alepha/ui 0.11.5 → 0.11.6

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/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "mantine"
7
7
  ],
8
8
  "author": "Feunard",
9
- "version": "0.11.5",
9
+ "version": "0.11.6",
10
10
  "type": "module",
11
11
  "engines": {
12
12
  "node": ">=22.0.0"
@@ -20,32 +20,33 @@
20
20
  "src"
21
21
  ],
22
22
  "dependencies": {
23
- "@alepha/core": "0.11.5",
24
- "@alepha/react": "0.11.5",
25
- "@alepha/react-form": "0.11.5",
26
- "@alepha/react-head": "0.11.5",
27
- "@alepha/react-i18n": "0.11.5",
28
- "@alepha/server": "0.11.5",
29
- "@mantine/core": "^8.3.6",
30
- "@mantine/dates": "^8.3.6",
31
- "@mantine/hooks": "^8.3.6",
32
- "@mantine/modals": "^8.3.6",
33
- "@mantine/notifications": "^8.3.6",
34
- "@mantine/nprogress": "^8.3.6",
35
- "@mantine/spotlight": "^8.3.6",
23
+ "@alepha/core": "0.11.6",
24
+ "@alepha/datetime": "0.11.6",
25
+ "@alepha/react": "0.11.6",
26
+ "@alepha/react-form": "0.11.6",
27
+ "@alepha/react-head": "0.11.6",
28
+ "@alepha/react-i18n": "0.11.6",
29
+ "@alepha/server": "0.11.6",
30
+ "@mantine/core": "^8.3.7",
31
+ "@mantine/dates": "^8.3.7",
32
+ "@mantine/hooks": "^8.3.7",
33
+ "@mantine/modals": "^8.3.7",
34
+ "@mantine/notifications": "^8.3.7",
35
+ "@mantine/nprogress": "^8.3.7",
36
+ "@mantine/spotlight": "^8.3.7",
36
37
  "@tabler/icons-react": "^3.35.0",
37
38
  "dayjs": "^1.11.19"
38
39
  },
39
40
  "devDependencies": {
40
- "@alepha/cli": "0.11.5",
41
- "@alepha/vite": "0.11.5",
42
- "@biomejs/biome": "^2.3.3",
41
+ "@alepha/cli": "0.11.6",
42
+ "@alepha/vite": "0.11.6",
43
+ "@biomejs/biome": "^2.3.4",
43
44
  "react": "^19.2.0",
44
45
  "react-dom": "^19.2.0",
45
- "tsdown": "^0.16.0",
46
+ "tsdown": "^0.16.1",
46
47
  "typescript": "^5.9.3",
47
- "vite": "^7.1.12",
48
- "vitest": "^4.0.6"
48
+ "vite": "^7.2.2",
49
+ "vitest": "^4.0.8"
49
50
  },
50
51
  "peerDependencies": {
51
52
  "react": "*",
@@ -277,7 +277,7 @@ const ActionButton = (_props: ActionProps) => {
277
277
 
278
278
  const renderAction = () => {
279
279
  if ("href" in restProps && restProps.href) {
280
- if (restProps.href.startsWith("http")) {
280
+ if (restProps.href.startsWith("http") || restProps.target) {
281
281
  return (
282
282
  <ActionHrefButton {...restProps} href={restProps.href}>
283
283
  {restProps.children}
@@ -311,6 +311,13 @@ const ActionButton = (_props: ActionProps) => {
311
311
  }
312
312
 
313
313
  if ("form" in restProps && restProps.form) {
314
+ if (restProps.type === "reset") {
315
+ return (
316
+ <ActionResetButton {...restProps} form={restProps.form}>
317
+ {restProps.children}
318
+ </ActionResetButton>
319
+ );
320
+ }
314
321
  return (
315
322
  <ActionSubmitButton {...restProps} form={restProps.form}>
316
323
  {restProps.children}
@@ -345,10 +352,18 @@ const ActionButton = (_props: ActionProps) => {
345
352
 
346
353
  // Wrap with Tooltip if provided
347
354
  if (tooltip) {
355
+ // openDelay: 1000 -> like HTML title attribute
356
+ const defaultTooltipProps: Partial<TooltipProps> = {
357
+ openDelay: 1000,
358
+ };
348
359
  const tooltipProps: TooltipProps =
349
360
  typeof tooltip === "string"
350
- ? { label: tooltip, children: actionElement }
351
- : { ...tooltip, children: actionElement };
361
+ ? {
362
+ ...defaultTooltipProps,
363
+ label: tooltip,
364
+ children: actionElement,
365
+ }
366
+ : { ...defaultTooltipProps, ...tooltip, children: actionElement };
352
367
 
353
368
  return <Tooltip {...tooltipProps} />;
354
369
  }
@@ -366,6 +381,7 @@ export default ActionButton;
366
381
 
367
382
  export interface ActionSubmitButtonProps extends ButtonProps {
368
383
  form: FormModel<any>;
384
+ type?: "submit" | "reset";
369
385
  }
370
386
 
371
387
  /**
@@ -386,6 +402,16 @@ const ActionSubmitButton = (props: ActionSubmitButtonProps) => {
386
402
  );
387
403
  };
388
404
 
405
+ const ActionResetButton = (props: ActionSubmitButtonProps) => {
406
+ const { form, ...buttonProps } = props;
407
+ const state = useFormState(form);
408
+ return (
409
+ <Button {...buttonProps} disabled={state.loading} type={"reset"}>
410
+ {props.children}
411
+ </Button>
412
+ );
413
+ };
414
+
389
415
  // ---------------------------------------------------------------------------------------------------------------------
390
416
 
391
417
  // Action with useAction Hook
@@ -20,7 +20,10 @@ const ToggleSidebarButton = () => {
20
20
  variant={"subtle"}
21
21
  size={"md"}
22
22
  onClick={() => setCollapsed(!collapsed)}
23
- tooltip={collapsed ? "Expand sidebar" : "Collapse sidebar"}
23
+ tooltip={{
24
+ position: "right",
25
+ label: collapsed ? "Show sidebar" : "Hide sidebar",
26
+ }}
24
27
  />
25
28
  );
26
29
  };
@@ -6,8 +6,6 @@ import {
6
6
  type FileInputProps,
7
7
  Flex,
8
8
  Input,
9
- NumberInput,
10
- type NumberInputProps,
11
9
  PasswordInput,
12
10
  type PasswordInputProps,
13
11
  Switch,
@@ -28,6 +26,8 @@ import {
28
26
  parseInput,
29
27
  } from "../../utils/parseInput.ts";
30
28
  import ControlDate from "./ControlDate.tsx";
29
+ import ControlNumber, { type ControlNumberProps } from "./ControlNumber.tsx";
30
+ import ControlQueryBuilder from "./ControlQueryBuilder.tsx";
31
31
  import ControlSelect, { type ControlSelectProps } from "./ControlSelect.tsx";
32
32
 
33
33
  export interface ControlProps extends GenericControlProps {
@@ -36,12 +36,13 @@ export interface ControlProps extends GenericControlProps {
36
36
  select?: boolean | Partial<ControlSelectProps>;
37
37
  password?: boolean | PasswordInputProps;
38
38
  switch?: boolean | SwitchProps;
39
- number?: boolean | NumberInputProps;
39
+ number?: boolean | Partial<ControlNumberProps>;
40
40
  file?: boolean | FileInputProps;
41
41
  color?: boolean | ColorInputProps;
42
42
  date?: boolean | DateInputProps;
43
43
  datetime?: boolean | DateTimePickerProps;
44
44
  time?: boolean | TimeInputProps;
45
+ query?: any; // Enable query builder mode with schema-aware autocomplete
45
46
  custom?: ComponentType<CustomControlProps>;
46
47
  }
47
48
 
@@ -62,6 +63,7 @@ export interface ControlProps extends GenericControlProps {
62
63
  * - DateInput (for date format)
63
64
  * - DateTimePicker (for date-time format)
64
65
  * - TimeInput (for time format)
66
+ * - QueryBuilder (for building type-safe queries with autocomplete)
65
67
  * - Custom component
66
68
  *
67
69
  * Automatically handles labels, descriptions, error messages, required state, and default icons.
@@ -78,6 +80,22 @@ const Control = (_props: ControlProps) => {
78
80
  ...schema.$control,
79
81
  };
80
82
 
83
+ //region <QueryBuilder/>
84
+ if (props.query) {
85
+ return (
86
+ <Input.Wrapper {...inputProps}>
87
+ <ControlQueryBuilder
88
+ schema={props.query}
89
+ value={props.input.props.value}
90
+ onChange={(value) => {
91
+ props.input.set(value);
92
+ }}
93
+ />
94
+ </Input.Wrapper>
95
+ );
96
+ }
97
+ //endregion
98
+
81
99
  //region <Custom/>
82
100
  if (props.custom) {
83
101
  const Custom = props.custom;
@@ -104,16 +122,15 @@ const Control = (_props: ControlProps) => {
104
122
  (props.input.schema.type === "number" ||
105
123
  props.input.schema.type === "integer"))
106
124
  ) {
107
- const numberInputProps =
125
+ const controlNumberProps =
108
126
  typeof props.number === "object" ? props.number : {};
109
- const { type, ...inputPropsWithoutType } = props.input.props;
110
127
  return (
111
- <NumberInput
112
- {...inputProps}
113
- id={id}
114
- leftSection={icon}
115
- {...inputPropsWithoutType}
116
- {...numberInputProps}
128
+ <ControlNumber
129
+ input={props.input}
130
+ title={props.title}
131
+ description={props.description}
132
+ icon={icon}
133
+ {...controlNumberProps}
117
134
  />
118
135
  );
119
136
  }
@@ -0,0 +1,96 @@
1
+ import { useEvents } from "@alepha/react";
2
+ import { useFormState } from "@alepha/react-form";
3
+ import {
4
+ Input,
5
+ NumberInput,
6
+ type NumberInputProps,
7
+ Slider,
8
+ type SliderProps,
9
+ } from "@mantine/core";
10
+ import { useRef, useState } from "react";
11
+ import {
12
+ type GenericControlProps,
13
+ parseInput,
14
+ } from "../../utils/parseInput.ts";
15
+
16
+ export interface ControlNumberProps extends GenericControlProps {
17
+ numberInputProps?: Partial<NumberInputProps>;
18
+ sliderProps?: Partial<SliderProps>;
19
+ }
20
+
21
+ /**
22
+ *
23
+ */
24
+ const ControlNumber = (props: ControlNumberProps) => {
25
+ const form = useFormState(props.input);
26
+ const { inputProps, id, icon } = parseInput(props, form);
27
+ const ref = useRef<HTMLInputElement | null>(null);
28
+
29
+ // HTML Reset doesn't trigger on <NumberInput /> so we handle it manually
30
+
31
+ const [value, setValue] = useState<number | undefined>(
32
+ props.input.props.defaultValue,
33
+ );
34
+
35
+ useEvents(
36
+ {
37
+ "form:reset": (event) => {
38
+ if (event.id === props.input?.form.id && ref.current) {
39
+ setValue(props.input.props.defaultValue);
40
+ }
41
+ },
42
+ },
43
+ [props.input],
44
+ );
45
+
46
+ if (!props.input?.props) {
47
+ return null;
48
+ }
49
+
50
+ const { type, ...inputPropsWithoutType } = props.input.props;
51
+
52
+ if (props.sliderProps) {
53
+ return (
54
+ <Input.Wrapper {...inputProps}>
55
+ <div
56
+ style={{
57
+ height: 32,
58
+ padding: 8,
59
+ }}
60
+ >
61
+ <Slider
62
+ {...inputProps}
63
+ ref={ref}
64
+ id={id}
65
+ {...inputPropsWithoutType}
66
+ {...props.sliderProps}
67
+ value={value}
68
+ onChange={(val) => {
69
+ setValue(val);
70
+ props.input.set(val);
71
+ }}
72
+ />
73
+ </div>
74
+ </Input.Wrapper>
75
+ );
76
+ }
77
+
78
+ return (
79
+ <NumberInput
80
+ {...inputProps}
81
+ ref={ref}
82
+ id={id}
83
+ leftSection={icon}
84
+ {...inputPropsWithoutType}
85
+ {...props.numberInputProps}
86
+ value={value ?? ""}
87
+ onChange={(val) => {
88
+ const newValue = val !== null ? Number(val) : undefined;
89
+ setValue(newValue);
90
+ props.input.set(newValue);
91
+ }}
92
+ />
93
+ );
94
+ };
95
+
96
+ export default ControlNumber;
@@ -0,0 +1,248 @@
1
+ import type { TObject } from "@alepha/core";
2
+ import {
3
+ ActionIcon,
4
+ Badge,
5
+ Code,
6
+ Divider,
7
+ Group,
8
+ Popover,
9
+ Stack,
10
+ Text,
11
+ TextInput,
12
+ type TextInputProps,
13
+ } from "@mantine/core";
14
+ import { IconFilter, IconX } from "@tabler/icons-react";
15
+ import { useRef, useState } from "react";
16
+ import {
17
+ extractSchemaFields,
18
+ OPERATOR_INFO,
19
+ type SchemaField,
20
+ } from "../../utils/extractSchemaFields.ts";
21
+
22
+ export interface ControlQueryBuilderProps
23
+ extends Omit<TextInputProps, "value" | "onChange"> {
24
+ schema?: TObject;
25
+ value?: string;
26
+ onChange?: (value: string) => void;
27
+ placeholder?: string;
28
+ }
29
+
30
+ /**
31
+ * Query builder with text input and help popover.
32
+ * Generates query strings for parseQueryString syntax.
33
+ */
34
+ const ControlQueryBuilder = ({
35
+ schema,
36
+ value = "",
37
+ onChange,
38
+ placeholder = "Enter query or click help for assistance...",
39
+ ...textInputProps
40
+ }: ControlQueryBuilderProps) => {
41
+ const [helpOpened, setHelpOpened] = useState(false);
42
+ const [textValue, setTextValue] = useState(value);
43
+ const inputRef = useRef<HTMLInputElement>(null);
44
+ const fields = schema ? extractSchemaFields(schema) : [];
45
+
46
+ const handleTextChange = (newValue: string) => {
47
+ setTextValue(newValue);
48
+ onChange?.(newValue);
49
+ };
50
+
51
+ const handleClear = () => {
52
+ setTextValue("");
53
+ onChange?.("");
54
+ };
55
+
56
+ const handleInsert = (text: string) => {
57
+ const newValue = textValue ? `${textValue}${text} ` : `${text} `;
58
+ setTextValue(newValue);
59
+ onChange?.(newValue);
60
+ // Refocus the input after inserting
61
+ setTimeout(() => {
62
+ inputRef.current?.focus();
63
+ }, 0);
64
+ };
65
+
66
+ return (
67
+ <Popover
68
+ width={800}
69
+ position="bottom-start"
70
+ shadow="md"
71
+ opened={helpOpened}
72
+ onChange={setHelpOpened}
73
+ closeOnClickOutside
74
+ closeOnEscape
75
+ withArrow
76
+ arrowSize={14}
77
+ transitionProps={{
78
+ transition: "fade-down",
79
+ duration: 200,
80
+ timingFunction: "ease",
81
+ }}
82
+ >
83
+ <Popover.Target>
84
+ <TextInput
85
+ ref={inputRef}
86
+ placeholder={placeholder}
87
+ value={textValue}
88
+ onChange={(e) => handleTextChange(e.currentTarget.value)}
89
+ onFocus={() => setHelpOpened(true)}
90
+ leftSection={<IconFilter size={16} />}
91
+ rightSection={
92
+ textValue && (
93
+ <ActionIcon
94
+ size="sm"
95
+ variant="subtle"
96
+ color="gray"
97
+ onClick={handleClear}
98
+ >
99
+ <IconX size={14} />
100
+ </ActionIcon>
101
+ )
102
+ }
103
+ {...textInputProps}
104
+ />
105
+ </Popover.Target>
106
+ <Popover.Dropdown>
107
+ <QueryHelp fields={fields} onInsert={handleInsert} />
108
+ </Popover.Dropdown>
109
+ </Popover>
110
+ );
111
+ };
112
+
113
+ // ---------------------------------------------------------------------------------------------------------------------
114
+ // Query Help Component
115
+ // ---------------------------------------------------------------------------------------------------------------------
116
+
117
+ interface QueryHelpProps {
118
+ fields: SchemaField[];
119
+ onInsert: (text: string) => void;
120
+ }
121
+
122
+ function QueryHelp({ fields, onInsert }: QueryHelpProps) {
123
+ return (
124
+ <Group gap="md" align="flex-start" wrap="nowrap">
125
+ {/* Left Column: Operators */}
126
+ <Stack gap="md" style={{ flex: 1 }}>
127
+ {/* Available Operators */}
128
+ <Stack gap="xs">
129
+ <Text size="sm" fw={600}>
130
+ Operators
131
+ </Text>
132
+ <Stack gap={4}>
133
+ {Object.entries(OPERATOR_INFO).map(([key, info]) => (
134
+ <Group key={key} gap="xs" wrap="nowrap">
135
+ <Code
136
+ style={{
137
+ minWidth: 35,
138
+ textAlign: "center",
139
+ cursor: "pointer",
140
+ }}
141
+ onClick={() => onInsert(info.symbol)}
142
+ >
143
+ {info.symbol}
144
+ </Code>
145
+ <Text size="xs" c="dimmed" style={{ flex: 1 }}>
146
+ {info.label}
147
+ </Text>
148
+ </Group>
149
+ ))}
150
+ </Stack>
151
+ </Stack>
152
+
153
+ <Divider />
154
+
155
+ {/* Logic Operators */}
156
+ <Stack gap="xs">
157
+ <Text size="sm" fw={600}>
158
+ Logic
159
+ </Text>
160
+ <Stack gap={4}>
161
+ <Group gap="xs" wrap="nowrap">
162
+ <Code
163
+ style={{
164
+ minWidth: 35,
165
+ textAlign: "center",
166
+ cursor: "pointer",
167
+ }}
168
+ onClick={() => onInsert("&")}
169
+ >
170
+ &
171
+ </Code>
172
+ <Text size="xs" c="dimmed">
173
+ AND
174
+ </Text>
175
+ </Group>
176
+ <Group gap="xs" wrap="nowrap">
177
+ <Code
178
+ style={{
179
+ minWidth: 35,
180
+ textAlign: "center",
181
+ cursor: "pointer",
182
+ }}
183
+ onClick={() => onInsert("|")}
184
+ >
185
+ |
186
+ </Code>
187
+ <Text size="xs" c="dimmed">
188
+ OR
189
+ </Text>
190
+ </Group>
191
+ </Stack>
192
+ </Stack>
193
+ </Stack>
194
+
195
+ {/* Divider */}
196
+ {fields.length > 0 && <Divider orientation="vertical" />}
197
+
198
+ {/* Right Column: Fields */}
199
+ {fields.length > 0 && (
200
+ <Stack gap="xs" style={{ flex: 2 }}>
201
+ <Text size="sm" fw={600}>
202
+ Fields
203
+ </Text>
204
+ <Stack gap={4} style={{ maxHeight: 300, overflowY: "auto" }}>
205
+ {fields.map((field) => (
206
+ <Group key={field.path} gap="xs" wrap="nowrap" align="flex-start">
207
+ <Code
208
+ style={{ minWidth: 120, cursor: "pointer" }}
209
+ onClick={() => onInsert(field.path)}
210
+ >
211
+ {field.path}
212
+ </Code>
213
+ <Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
214
+ <Text size="xs" c="dimmed" lineClamp={1}>
215
+ {field.description || field.type}
216
+ </Text>
217
+ {field.enum && (
218
+ <Group gap={4} wrap="wrap">
219
+ {field.enum.map((enumValue) => (
220
+ <Code
221
+ key={enumValue}
222
+ style={{
223
+ cursor: "pointer",
224
+ fontStyle: "italic",
225
+ fontSize: "0.75rem",
226
+ }}
227
+ c="blue"
228
+ onClick={() => onInsert(enumValue)}
229
+ >
230
+ {enumValue}
231
+ </Code>
232
+ ))}
233
+ </Group>
234
+ )}
235
+ </Stack>
236
+ <Badge size="xs" variant="light" style={{ flexShrink: 0 }}>
237
+ {field.type}
238
+ </Badge>
239
+ </Group>
240
+ ))}
241
+ </Stack>
242
+ </Stack>
243
+ )}
244
+ </Group>
245
+ );
246
+ }
247
+
248
+ export default ControlQueryBuilder;
@@ -19,6 +19,7 @@ export interface TypeFormProps<T extends TObject> {
19
19
  lg?: number;
20
20
  xl?: number;
21
21
  };
22
+ schema?: TObject;
22
23
  children?: (input: FormModel<T>["input"]) => ReactNode;
23
24
  controlProps?: Partial<Omit<ControlProps, "input">>;
24
25
  skipFormElement?: boolean;
@@ -63,11 +64,12 @@ const TypeForm = <T extends TObject>(props: TypeFormProps<T>) => {
63
64
  submitButtonProps,
64
65
  } = props;
65
66
 
66
- if (!form.options?.schema?.properties) {
67
+ const schema = props.schema || form.options.schema;
68
+ if (!schema?.properties) {
67
69
  return null;
68
70
  }
69
71
 
70
- const fieldNames = Object.keys(form.options.schema.properties);
72
+ const fieldNames = Object.keys(schema.properties);
71
73
 
72
74
  // Filter out unsupported field types (objects only, arrays are now supported)
73
75
  const supportedFields = fieldNames.filter((fieldName) => {
@@ -140,11 +142,11 @@ const TypeForm = <T extends TObject>(props: TypeFormProps<T>) => {
140
142
  <Flex direction={"column"} gap={"sm"}>
141
143
  {renderFields()}
142
144
  {!skipSubmitButton && (
143
- <Flex>
145
+ <Flex gap={"sm"}>
144
146
  <ActionButton form={form} {...submitButtonProps}>
145
147
  {submitButtonProps?.children ?? "Submit"}
146
148
  </ActionButton>
147
- <button type={"reset"}>Reset</button>
149
+ <ActionButton type={"reset"}>Reset</ActionButton>
148
150
  </Flex>
149
151
  )}
150
152
  </Flex>
@@ -27,7 +27,14 @@ export interface AdminShellProps {
27
27
 
28
28
  declare module "@alepha/core" {
29
29
  interface State {
30
+ /**
31
+ * Whether the sidebar is opened or closed.
32
+ */
30
33
  "alepha.ui.sidebar.opened"?: boolean;
34
+
35
+ /**
36
+ * Whether the sidebar is collapsed (narrow) or expanded (wide).
37
+ */
31
38
  "alepha.ui.sidebar.collapsed"?: boolean;
32
39
  }
33
40
  }