@alepha/ui 0.11.5 → 0.11.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.
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.7",
10
10
  "type": "module",
11
11
  "engines": {
12
12
  "node": ">=22.0.0"
@@ -20,32 +20,34 @@
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.7",
24
+ "@alepha/datetime": "0.11.7",
25
+ "@alepha/postgres": "0.11.7",
26
+ "@alepha/react": "0.11.7",
27
+ "@alepha/react-form": "0.11.7",
28
+ "@alepha/react-head": "0.11.7",
29
+ "@alepha/react-i18n": "0.11.7",
30
+ "@alepha/server": "0.11.7",
31
+ "@mantine/core": "^8.3.7",
32
+ "@mantine/dates": "^8.3.7",
33
+ "@mantine/hooks": "^8.3.7",
34
+ "@mantine/modals": "^8.3.7",
35
+ "@mantine/notifications": "^8.3.7",
36
+ "@mantine/nprogress": "^8.3.7",
37
+ "@mantine/spotlight": "^8.3.7",
36
38
  "@tabler/icons-react": "^3.35.0",
37
39
  "dayjs": "^1.11.19"
38
40
  },
39
41
  "devDependencies": {
40
- "@alepha/cli": "0.11.5",
41
- "@alepha/vite": "0.11.5",
42
- "@biomejs/biome": "^2.3.3",
42
+ "@alepha/cli": "0.11.7",
43
+ "@alepha/vite": "0.11.7",
44
+ "@biomejs/biome": "^2.3.5",
43
45
  "react": "^19.2.0",
44
46
  "react-dom": "^19.2.0",
45
- "tsdown": "^0.16.0",
47
+ "tsdown": "^0.16.4",
46
48
  "typescript": "^5.9.3",
47
- "vite": "^7.1.12",
48
- "vitest": "^4.0.6"
49
+ "vite": "^7.2.2",
50
+ "vitest": "^4.0.8"
49
51
  },
50
52
  "peerDependencies": {
51
53
  "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
  };
@@ -0,0 +1,352 @@
1
+ import {
2
+ ActionIcon,
3
+ Box,
4
+ Collapse,
5
+ CopyButton,
6
+ type MantineSize,
7
+ Text,
8
+ Tooltip,
9
+ } from "@mantine/core";
10
+ import {
11
+ IconCheck,
12
+ IconChevronDown,
13
+ IconChevronRight,
14
+ IconCopy,
15
+ } from "@tabler/icons-react";
16
+ import { type ReactNode, useState } from "react";
17
+
18
+ interface JsonViewerProps {
19
+ data: any;
20
+ defaultExpanded?: boolean;
21
+ maxDepth?: number;
22
+ copyable?: boolean;
23
+ size?: MantineSize;
24
+ }
25
+
26
+ interface JsonNodeProps {
27
+ name?: string;
28
+ value: any;
29
+ depth: number;
30
+ maxDepth: number;
31
+ isLast?: boolean;
32
+ isArrayItem?: boolean;
33
+ size?: MantineSize;
34
+ }
35
+
36
+ const getSizeConfig = (size: MantineSize = "sm") => {
37
+ const configs = {
38
+ xs: { text: "xs", icon: 12, indent: 16, gap: 2 },
39
+ sm: { text: "sm", icon: 14, indent: 20, gap: 4 },
40
+ md: { text: "md", icon: 16, indent: 24, gap: 6 },
41
+ lg: { text: "lg", icon: 18, indent: 28, gap: 8 },
42
+ xl: { text: "xl", icon: 20, indent: 32, gap: 10 },
43
+ };
44
+ return configs[size] || configs.sm;
45
+ };
46
+
47
+ const JsonNode = ({
48
+ name,
49
+ value,
50
+ depth,
51
+ maxDepth,
52
+ isLast = false,
53
+ isArrayItem = false,
54
+ size = "sm",
55
+ }: JsonNodeProps) => {
56
+ const [expanded, setExpanded] = useState(depth < 2);
57
+ const sizeConfig = getSizeConfig(size);
58
+
59
+ const getValueType = (val: any): string => {
60
+ if (val === null) return "null";
61
+ if (val === undefined) return "undefined";
62
+ if (Array.isArray(val)) return "array";
63
+ return typeof val;
64
+ };
65
+
66
+ const valueType = getValueType(value);
67
+
68
+ const renderPrimitive = (val: any): ReactNode => {
69
+ const type = getValueType(val);
70
+
71
+ switch (type) {
72
+ case "string":
73
+ return (
74
+ <Text
75
+ component="span"
76
+ c="teal"
77
+ ff="monospace"
78
+ size={sizeConfig.text}
79
+ style={{ whiteSpace: "nowrap" }}
80
+ >
81
+ "{val}"
82
+ </Text>
83
+ );
84
+ case "number":
85
+ return (
86
+ <Text
87
+ component="span"
88
+ c="blue"
89
+ ff="monospace"
90
+ size={sizeConfig.text}
91
+ style={{ whiteSpace: "nowrap" }}
92
+ >
93
+ {val}
94
+ </Text>
95
+ );
96
+ case "boolean":
97
+ return (
98
+ <Text
99
+ component="span"
100
+ c="violet"
101
+ ff="monospace"
102
+ size={sizeConfig.text}
103
+ style={{ whiteSpace: "nowrap" }}
104
+ >
105
+ {String(val)}
106
+ </Text>
107
+ );
108
+ case "null":
109
+ return (
110
+ <Text
111
+ component="span"
112
+ c="dimmed"
113
+ ff="monospace"
114
+ size={sizeConfig.text}
115
+ style={{ whiteSpace: "nowrap" }}
116
+ >
117
+ null
118
+ </Text>
119
+ );
120
+ case "undefined":
121
+ return (
122
+ <Text
123
+ component="span"
124
+ c="dimmed"
125
+ ff="monospace"
126
+ size={sizeConfig.text}
127
+ style={{ whiteSpace: "nowrap" }}
128
+ >
129
+ undefined
130
+ </Text>
131
+ );
132
+ default:
133
+ return (
134
+ <Text
135
+ component="span"
136
+ ff="monospace"
137
+ size={sizeConfig.text}
138
+ style={{ whiteSpace: "nowrap" }}
139
+ >
140
+ {String(val)}
141
+ </Text>
142
+ );
143
+ }
144
+ };
145
+
146
+ const renderKey = () => {
147
+ if (!name) return null;
148
+ return (
149
+ <Text
150
+ component="span"
151
+ c="cyan"
152
+ ff="monospace"
153
+ fw={500}
154
+ size={sizeConfig.text}
155
+ >
156
+ {isArrayItem ? `[${name}]` : `"${name}"`}:
157
+ </Text>
158
+ );
159
+ };
160
+
161
+ if (valueType === "object" || valueType === "array") {
162
+ const isObject = valueType === "object";
163
+ const entries = isObject
164
+ ? Object.entries(value)
165
+ : value.map((v: any, i: number) => [i, v]);
166
+ const isEmpty = entries.length === 0;
167
+ const canExpand = depth < maxDepth && !isEmpty;
168
+
169
+ const preview = isObject ? "{...}" : "[...]";
170
+ const brackets = isObject ? ["{", "}"] : ["[", "]"];
171
+
172
+ return (
173
+ <Box>
174
+ <Box
175
+ style={{
176
+ display: "flex",
177
+ alignItems: "center",
178
+ gap: sizeConfig.gap,
179
+ minWidth: "max-content",
180
+ }}
181
+ >
182
+ {canExpand && (
183
+ <ActionIcon
184
+ size="xs"
185
+ variant="transparent"
186
+ c="dimmed"
187
+ onClick={() => setExpanded(!expanded)}
188
+ style={{ cursor: "pointer", flexShrink: 0 }}
189
+ >
190
+ {expanded ? (
191
+ <IconChevronDown size={sizeConfig.icon} />
192
+ ) : (
193
+ <IconChevronRight size={sizeConfig.icon} />
194
+ )}
195
+ </ActionIcon>
196
+ )}
197
+ {!canExpand && (
198
+ <Box w={sizeConfig.icon + 6} style={{ flexShrink: 0 }} />
199
+ )}
200
+ <Box style={{ flexShrink: 0 }}>{renderKey()}</Box>{" "}
201
+ <Text
202
+ component="span"
203
+ c="dimmed"
204
+ ff="monospace"
205
+ size={sizeConfig.text}
206
+ style={{ flexShrink: 0 }}
207
+ >
208
+ {brackets[0]}
209
+ </Text>
210
+ {!expanded && !isEmpty && (
211
+ <Text
212
+ component="span"
213
+ c="dimmed"
214
+ ff="monospace"
215
+ fs="italic"
216
+ size={sizeConfig.text}
217
+ style={{ flexShrink: 0 }}
218
+ >
219
+ {preview}
220
+ </Text>
221
+ )}
222
+ {(isEmpty || !expanded) && (
223
+ <Text
224
+ component="span"
225
+ c="dimmed"
226
+ ff="monospace"
227
+ size={sizeConfig.text}
228
+ style={{ flexShrink: 0 }}
229
+ >
230
+ {brackets[1]}
231
+ </Text>
232
+ )}
233
+ {!isEmpty && !expanded && (
234
+ <Text
235
+ component="span"
236
+ c="dimmed"
237
+ size={sizeConfig.text}
238
+ style={{ flexShrink: 0 }}
239
+ >
240
+ {entries.length} {entries.length === 1 ? "item" : "items"}
241
+ </Text>
242
+ )}
243
+ </Box>
244
+
245
+ <Collapse in={expanded && canExpand}>
246
+ <Box
247
+ pl={sizeConfig.indent}
248
+ style={{
249
+ borderLeft: "1px solid var(--mantine-color-default-border)",
250
+ marginLeft: Math.floor((sizeConfig.icon + 6) / 2),
251
+ }}
252
+ >
253
+ {entries.map(
254
+ ([key, val]: [string | number, any], index: number) => (
255
+ <JsonNode
256
+ key={String(key)}
257
+ name={String(key)}
258
+ value={val}
259
+ depth={depth + 1}
260
+ maxDepth={maxDepth}
261
+ isLast={index === entries.length - 1}
262
+ isArrayItem={!isObject}
263
+ size={size}
264
+ />
265
+ ),
266
+ )}
267
+ </Box>
268
+ <Box style={{ display: "flex", minWidth: "max-content" }}>
269
+ <Box w={sizeConfig.icon + 6} style={{ flexShrink: 0 }} />
270
+ <Text
271
+ c="dimmed"
272
+ ff="monospace"
273
+ size={sizeConfig.text}
274
+ style={{ flexShrink: 0 }}
275
+ >
276
+ {brackets[1]}
277
+ </Text>
278
+ </Box>
279
+ </Collapse>
280
+ </Box>
281
+ );
282
+ }
283
+
284
+ return (
285
+ <Box
286
+ style={{
287
+ display: "flex",
288
+ alignItems: "center",
289
+ gap: sizeConfig.gap,
290
+ minWidth: "max-content",
291
+ }}
292
+ >
293
+ <Box w={sizeConfig.icon + 6} style={{ flexShrink: 0 }} />
294
+ <Box style={{ flexShrink: 0 }}>{renderKey()}</Box>
295
+ <Box style={{ flexShrink: 0 }}>{renderPrimitive(value)}</Box>
296
+ {!isLast && (
297
+ <Text
298
+ component="span"
299
+ c="dimmed"
300
+ ff="monospace"
301
+ size={sizeConfig.text}
302
+ style={{ flexShrink: 0 }}
303
+ >
304
+ ,
305
+ </Text>
306
+ )}
307
+ </Box>
308
+ );
309
+ };
310
+
311
+ export const JsonViewer = ({
312
+ data,
313
+ defaultExpanded = true,
314
+ maxDepth = 10,
315
+ copyable = true,
316
+ size = "sm",
317
+ }: JsonViewerProps) => {
318
+ const sizeConfig = getSizeConfig(size);
319
+ const copyIconSize = sizeConfig.icon + 2;
320
+
321
+ return (
322
+ <Box pos="relative" w={"100%"}>
323
+ {copyable && (
324
+ <Box pos="absolute" top={0} right={0} style={{ zIndex: 1 }}>
325
+ <CopyButton value={JSON.stringify(data, null, 2)}>
326
+ {({ copied, copy }) => (
327
+ <Tooltip label={copied ? "Copied" : "Copy JSON"}>
328
+ <ActionIcon
329
+ color={copied ? "teal" : "gray"}
330
+ variant="subtle"
331
+ onClick={copy}
332
+ size={size}
333
+ >
334
+ {copied ? (
335
+ <IconCheck size={copyIconSize} />
336
+ ) : (
337
+ <IconCopy size={copyIconSize} />
338
+ )}
339
+ </ActionIcon>
340
+ </Tooltip>
341
+ )}
342
+ </CopyButton>
343
+ </Box>
344
+ )}
345
+ <Box pt={copyable ? 30 : 0} style={{ overflowX: "auto" }}>
346
+ <JsonNode value={data} depth={0} maxDepth={maxDepth} size={size} />
347
+ </Box>
348
+ </Box>
349
+ );
350
+ };
351
+
352
+ export default JsonViewer;
@@ -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
+ <ControlQueryBuilder
87
+ {...props.input.props}
88
+ {...inputProps}
89
+ schema={props.query}
90
+ value={props.input.props.value}
91
+ onChange={(value) => {
92
+ props.input.set(value);
93
+ }}
94
+ />
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;