@alepha/ui 0.10.6 → 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.
@@ -0,0 +1,112 @@
1
+ import { useFormState } from "@alepha/react-form";
2
+ import {
3
+ DateInput,
4
+ type DateInputProps,
5
+ DateTimePicker,
6
+ type DateTimePickerProps,
7
+ TimeInput,
8
+ type TimeInputProps,
9
+ } from "@mantine/dates";
10
+ import { type GenericControlProps, parseInput } from "./Control.tsx";
11
+
12
+ export interface ControlDateProps extends GenericControlProps {
13
+ date?: boolean | DateInputProps;
14
+ datetime?: boolean | DateTimePickerProps;
15
+ time?: boolean | TimeInputProps;
16
+ }
17
+
18
+ /**
19
+ * ControlDate component for handling date, datetime, and time inputs.
20
+ *
21
+ * Features:
22
+ * - DateInput for date format
23
+ * - DateTimePicker for date-time format
24
+ * - TimeInput for time format
25
+ *
26
+ * Automatically detects date formats from schema and renders appropriate picker.
27
+ */
28
+ const ControlDate = (props: ControlDateProps) => {
29
+ const form = useFormState(props.input);
30
+ const { inputProps, id, icon } = parseInput(props, form);
31
+ if (!props.input?.props) {
32
+ return null;
33
+ }
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
+ // region <DateTimePicker/>
44
+ if (props.datetime || format === "date-time") {
45
+ const dateTimePickerProps =
46
+ typeof props.datetime === "object" ? props.datetime : {};
47
+ return (
48
+ <DateTimePicker
49
+ {...inputProps}
50
+ id={id}
51
+ leftSection={icon}
52
+ defaultValue={
53
+ props.input.props.defaultValue
54
+ ? new Date(props.input.props.defaultValue)
55
+ : undefined
56
+ }
57
+ onChange={(value) => {
58
+ props.input.set(value ? new Date(value).toISOString() : undefined);
59
+ }}
60
+ {...dateTimePickerProps}
61
+ />
62
+ );
63
+ }
64
+ //endregion
65
+
66
+ // region <DateInput/>
67
+ if (props.date || format === "date") {
68
+ const dateInputProps = typeof props.date === "object" ? props.date : {};
69
+ return (
70
+ <DateInput
71
+ {...inputProps}
72
+ id={id}
73
+ leftSection={icon}
74
+ defaultValue={
75
+ props.input.props.defaultValue
76
+ ? new Date(props.input.props.defaultValue)
77
+ : undefined
78
+ }
79
+ onChange={(value) => {
80
+ props.input.set(
81
+ value ? new Date(value).toISOString().slice(0, 10) : undefined,
82
+ );
83
+ }}
84
+ {...dateInputProps}
85
+ />
86
+ );
87
+ }
88
+ //endregion
89
+
90
+ // region <TimeInput/>
91
+ if (props.time || format === "time") {
92
+ const timeInputProps = typeof props.time === "object" ? props.time : {};
93
+ return (
94
+ <TimeInput
95
+ {...inputProps}
96
+ id={id}
97
+ leftSection={icon}
98
+ defaultValue={props.input.props.defaultValue}
99
+ onChange={(event) => {
100
+ props.input.set(event.currentTarget.value);
101
+ }}
102
+ {...timeInputProps}
103
+ />
104
+ );
105
+ }
106
+ //endregion
107
+
108
+ // Fallback - shouldn't happen
109
+ return null;
110
+ };
111
+
112
+ export default ControlDate;
@@ -0,0 +1,134 @@
1
+ import { useFormState } from "@alepha/react-form";
2
+ import {
3
+ MultiSelect,
4
+ type MultiSelectProps,
5
+ Select,
6
+ type SelectProps,
7
+ TagsInput,
8
+ type TagsInputProps,
9
+ } from "@mantine/core";
10
+ import { type GenericControlProps, parseInput } from "./Control.tsx";
11
+
12
+ export interface ControlSelectProps extends GenericControlProps {
13
+ select?: boolean | SelectProps;
14
+ multi?: boolean | MultiSelectProps;
15
+ tags?: boolean | TagsInputProps;
16
+ }
17
+
18
+ /**
19
+ * ControlSelect component for handling Select, MultiSelect, and TagsInput.
20
+ *
21
+ * Features:
22
+ * - Basic Select with enum support
23
+ * - MultiSelect for array of enums
24
+ * - TagsInput for array of strings (no enum)
25
+ * - Future: Lazy loading
26
+ * - Future: Searchable/filterable options
27
+ * - Future: Custom option rendering
28
+ *
29
+ * Automatically detects enum values and array types from schema.
30
+ */
31
+ const ControlSelect = (props: ControlSelectProps) => {
32
+ const form = useFormState(props.input);
33
+ const { inputProps, id, icon } = parseInput(props, form);
34
+ if (!props.input?.props) {
35
+ return null;
36
+ }
37
+
38
+ // Detect if schema is an array type
39
+ const isArray =
40
+ props.input.schema &&
41
+ "type" in props.input.schema &&
42
+ props.input.schema.type === "array";
43
+
44
+ // For arrays, check if items have enum (MultiSelect) or not (TagsInput)
45
+ let itemsEnum: string[] | undefined;
46
+ if (isArray && "items" in props.input.schema && props.input.schema.items) {
47
+ const items: any = props.input.schema.items;
48
+ if ("enum" in items && Array.isArray(items.enum)) {
49
+ itemsEnum = items.enum;
50
+ }
51
+ }
52
+
53
+ // Extract enum values from schema (for non-array select)
54
+ const enumValues =
55
+ props.input.schema &&
56
+ "enum" in props.input.schema &&
57
+ Array.isArray(props.input.schema.enum)
58
+ ? props.input.schema.enum
59
+ : [];
60
+
61
+ // region <TagsInput/> - for array of strings without enum
62
+ if ((isArray && !itemsEnum) || props.tags) {
63
+ const tagsInputProps = typeof props.tags === "object" ? props.tags : {};
64
+ return (
65
+ <TagsInput
66
+ {...inputProps}
67
+ id={id}
68
+ leftSection={icon}
69
+ defaultValue={
70
+ Array.isArray(props.input.props.defaultValue)
71
+ ? props.input.props.defaultValue
72
+ : []
73
+ }
74
+ onChange={(value) => {
75
+ props.input.set(value);
76
+ }}
77
+ {...tagsInputProps}
78
+ />
79
+ );
80
+ }
81
+ // endregion
82
+
83
+ // region <MultiSelect/> - for array of enums
84
+ if ((isArray && itemsEnum) || props.multi) {
85
+ const data =
86
+ itemsEnum?.map((value: string) => ({
87
+ value,
88
+ label: value,
89
+ })) || [];
90
+
91
+ const multiSelectProps = typeof props.multi === "object" ? props.multi : {};
92
+
93
+ return (
94
+ <MultiSelect
95
+ {...inputProps}
96
+ id={id}
97
+ leftSection={icon}
98
+ data={data}
99
+ defaultValue={
100
+ Array.isArray(props.input.props.defaultValue)
101
+ ? props.input.props.defaultValue
102
+ : []
103
+ }
104
+ onChange={(value) => {
105
+ props.input.set(value);
106
+ }}
107
+ {...multiSelectProps}
108
+ />
109
+ );
110
+ }
111
+ // endregion
112
+
113
+ // region <Select/> - for single enum value
114
+ const data = enumValues.map((value: string) => ({
115
+ value,
116
+ label: value,
117
+ }));
118
+
119
+ const selectProps = typeof props.select === "object" ? props.select : {};
120
+
121
+ return (
122
+ <Select
123
+ {...inputProps}
124
+ id={id}
125
+ leftSection={icon}
126
+ data={data}
127
+ {...props.input.props}
128
+ {...selectProps}
129
+ />
130
+ );
131
+ // endregion
132
+ };
133
+
134
+ export default ControlSelect;
@@ -0,0 +1,75 @@
1
+ import {
2
+ ActionIcon,
3
+ Flex,
4
+ SegmentedControl,
5
+ useComputedColorScheme,
6
+ useMantineColorScheme,
7
+ } from "@mantine/core";
8
+ import { IconMoon, IconSun } from "@tabler/icons-react";
9
+ import { useEffect, useState } from "react";
10
+
11
+ export interface DarkModeButtonProps {
12
+ mode?: "minimal" | "segmented";
13
+ size?: string | number;
14
+ variant?:
15
+ | "filled"
16
+ | "light"
17
+ | "outline"
18
+ | "default"
19
+ | "subtle"
20
+ | "transparent";
21
+ }
22
+
23
+ const DarkModeButton = (props: DarkModeButtonProps) => {
24
+ const { setColorScheme } = useMantineColorScheme();
25
+ const computedColorScheme = useComputedColorScheme("light");
26
+ const [colorScheme, setColorScheme2] = useState("default");
27
+ useEffect(() => {
28
+ setColorScheme2(computedColorScheme);
29
+ }, [computedColorScheme]);
30
+ const mode = props.mode ?? "minimal";
31
+
32
+ const toggleColorScheme = () => {
33
+ setColorScheme(computedColorScheme === "dark" ? "light" : "dark");
34
+ };
35
+
36
+ if (mode === "segmented") {
37
+ return (
38
+ <SegmentedControl
39
+ value={colorScheme}
40
+ onChange={(value) => setColorScheme(value as "light" | "dark")}
41
+ data={[
42
+ {
43
+ value: "light",
44
+ label: (
45
+ <Flex h={20} align="center" justify="center">
46
+ <IconSun size={16} />
47
+ </Flex>
48
+ ),
49
+ },
50
+ {
51
+ value: "dark",
52
+ label: (
53
+ <Flex h={20} align="center" justify="center">
54
+ <IconMoon size={16} />
55
+ </Flex>
56
+ ),
57
+ },
58
+ ]}
59
+ />
60
+ );
61
+ }
62
+
63
+ return (
64
+ <ActionIcon
65
+ onClick={toggleColorScheme}
66
+ variant={props.variant ?? "default"}
67
+ size={props.size ?? "lg"}
68
+ aria-label="Toggle color scheme"
69
+ >
70
+ {colorScheme === "dark" ? <IconSun size={20} /> : <IconMoon size={20} />}
71
+ </ActionIcon>
72
+ );
73
+ };
74
+
75
+ export default DarkModeButton;
@@ -0,0 +1,76 @@
1
+ import { Spotlight, type SpotlightActionData } from "@mantine/spotlight";
2
+ import {
3
+ IconDashboard,
4
+ IconFileText,
5
+ IconHome,
6
+ IconSearch,
7
+ IconSettings,
8
+ IconUser,
9
+ } from "@tabler/icons-react";
10
+ import type { ReactNode } from "react";
11
+
12
+ export interface OmnibarProps {
13
+ actions?: SpotlightActionData[];
14
+ shortcut?: string | string[];
15
+ searchPlaceholder?: string;
16
+ nothingFound?: ReactNode;
17
+ }
18
+
19
+ const defaultActions: SpotlightActionData[] = [
20
+ {
21
+ id: "home",
22
+ label: "Home",
23
+ description: "Go to home page",
24
+ onClick: () => console.log("Home"),
25
+ leftSection: <IconHome size={20} />,
26
+ },
27
+ {
28
+ id: "dashboard",
29
+ label: "Dashboard",
30
+ description: "View your dashboard",
31
+ onClick: () => console.log("Dashboard"),
32
+ leftSection: <IconDashboard size={20} />,
33
+ },
34
+ {
35
+ id: "documents",
36
+ label: "Documents",
37
+ description: "Browse all documents",
38
+ onClick: () => console.log("Documents"),
39
+ leftSection: <IconFileText size={20} />,
40
+ },
41
+ {
42
+ id: "profile",
43
+ label: "Profile",
44
+ description: "View and edit your profile",
45
+ onClick: () => console.log("Profile"),
46
+ leftSection: <IconUser size={20} />,
47
+ },
48
+ {
49
+ id: "settings",
50
+ label: "Settings",
51
+ description: "Manage application settings",
52
+ onClick: () => console.log("Settings"),
53
+ leftSection: <IconSettings size={20} />,
54
+ },
55
+ ];
56
+
57
+ const Omnibar = (props: OmnibarProps) => {
58
+ const actions = props.actions ?? defaultActions;
59
+ const shortcut = props.shortcut ?? "mod+K";
60
+ const searchPlaceholder = props.searchPlaceholder ?? "Search...";
61
+ const nothingFound = props.nothingFound ?? "Nothing found...";
62
+
63
+ return (
64
+ <Spotlight
65
+ actions={actions}
66
+ shortcut={shortcut}
67
+ searchProps={{
68
+ leftSection: <IconSearch size={20} />,
69
+ placeholder: searchPlaceholder,
70
+ }}
71
+ nothingFound={nothingFound}
72
+ />
73
+ );
74
+ };
75
+
76
+ export default Omnibar;
@@ -0,0 +1,158 @@
1
+ import type { TObject } from "@alepha/core";
2
+ import type { FormModel } from "@alepha/react-form";
3
+ import { Grid, Stack } from "@mantine/core";
4
+ import type { ReactNode } from "react";
5
+ import Action, { type ActionSubmitProps } from "./Action";
6
+ import Control, { type ControlProps } from "./Control";
7
+
8
+ export interface TypeFormProps<T extends TObject> {
9
+ form: FormModel<T>;
10
+ columns?:
11
+ | number
12
+ | {
13
+ base?: number;
14
+ xs?: number;
15
+ sm?: number;
16
+ md?: number;
17
+ lg?: number;
18
+ xl?: number;
19
+ };
20
+ children?: (input: FormModel<T>["input"]) => ReactNode;
21
+ controlProps?: Partial<Omit<ControlProps, "input">>;
22
+ skipFormElement?: boolean;
23
+ skipSubmitButton?: boolean;
24
+ submitButtonProps?: Partial<Omit<ActionSubmitProps, "form">>;
25
+ }
26
+
27
+ /**
28
+ * TypeForm component that automatically renders all form inputs based on schema.
29
+ * Uses the Control component to render individual fields and Mantine Grid for responsive layout.
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * import { t } from "alepha";
34
+ * import { useForm } from "@alepha/react-form";
35
+ * import { TypeForm } from "@alepha/ui";
36
+ *
37
+ * const form = useForm({
38
+ * schema: t.object({
39
+ * username: t.text(),
40
+ * email: t.text(),
41
+ * age: t.integer(),
42
+ * subscribe: t.boolean(),
43
+ * }),
44
+ * handler: (values) => {
45
+ * console.log(values);
46
+ * },
47
+ * });
48
+ *
49
+ * return <TypeForm form={form} columns={2} />;
50
+ * ```
51
+ */
52
+ const TypeForm = <T extends TObject>(props: TypeFormProps<T>) => {
53
+ const {
54
+ form,
55
+ columns = 1,
56
+ children,
57
+ controlProps,
58
+ skipFormElement = false,
59
+ skipSubmitButton = false,
60
+ submitButtonProps,
61
+ } = props;
62
+
63
+ if (!form.options?.schema?.properties) {
64
+ return null;
65
+ }
66
+
67
+ const fieldNames = Object.keys(form.options.schema.properties);
68
+
69
+ // Filter out unsupported field types (objects only, arrays are now supported)
70
+ const supportedFields = fieldNames.filter((fieldName) => {
71
+ const field = form.input[fieldName as keyof typeof form.input];
72
+ if (!field || typeof field !== "object" || !("schema" in field)) {
73
+ return false;
74
+ }
75
+
76
+ const schema: any = field.schema;
77
+
78
+ // Skip if it's an object (not supported by Control)
79
+ // Arrays are now supported via ControlSelect (MultiSelect/TagsInput)
80
+ if ("type" in schema) {
81
+ if (schema.type === "object") {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ // Check if it has properties (nested object)
87
+ if ("properties" in schema && schema.properties) {
88
+ return false;
89
+ }
90
+
91
+ return true;
92
+ });
93
+
94
+ // Handle column configuration with defaults: xs=1, sm=2, lg=3
95
+ const colSpan =
96
+ typeof columns === "number"
97
+ ? {
98
+ xs: 12,
99
+ sm: 6,
100
+ lg: 12 / 3,
101
+ }
102
+ : {
103
+ base: columns.base ? 12 / columns.base : undefined,
104
+ xs: columns.xs ? 12 / columns.xs : 12,
105
+ sm: columns.sm ? 12 / columns.sm : 6,
106
+ md: columns.md ? 12 / columns.md : undefined,
107
+ lg: columns.lg ? 12 / columns.lg : 4,
108
+ xl: columns.xl ? 12 / columns.xl : undefined,
109
+ };
110
+
111
+ const renderFields = () => {
112
+ if (children) {
113
+ return <>{children(form.input)}</>;
114
+ }
115
+
116
+ return (
117
+ <Grid>
118
+ {supportedFields.map((fieldName) => {
119
+ const field = form.input[fieldName as keyof typeof form.input];
120
+
121
+ // Type guard to ensure field has the expected structure
122
+ if (!field || typeof field !== "object" || !("schema" in field)) {
123
+ return null;
124
+ }
125
+
126
+ return (
127
+ <Grid.Col key={fieldName} span={colSpan}>
128
+ <Control input={field as any} {...controlProps} />
129
+ </Grid.Col>
130
+ );
131
+ })}
132
+ </Grid>
133
+ );
134
+ };
135
+
136
+ const content = (
137
+ <Stack>
138
+ {renderFields()}
139
+ {!skipSubmitButton && (
140
+ <Action form={form} {...submitButtonProps}>
141
+ {submitButtonProps?.children ?? "Submit"}
142
+ </Action>
143
+ )}
144
+ </Stack>
145
+ );
146
+
147
+ if (skipFormElement) {
148
+ return content;
149
+ }
150
+
151
+ return (
152
+ <form onSubmit={form.onSubmit} noValidate>
153
+ {content}
154
+ </form>
155
+ );
156
+ };
157
+
158
+ export default TypeForm;
@@ -0,0 +1,14 @@
1
+ import { useInject } from "@alepha/react";
2
+ import { ToastService } from "../services/ToastService.tsx";
3
+
4
+ /**
5
+ * Use this hook to access the Toast Service for showing notifications.
6
+ *
7
+ * @example
8
+ * const toast = useToast();
9
+ * toast.success({ message: "Operation completed successfully!" });
10
+ * toast.error({ title: "Error", message: "Something went wrong" });
11
+ */
12
+ export const useToast = (): ToastService => {
13
+ return useInject(ToastService);
14
+ };
package/src/index.ts CHANGED
@@ -1,17 +1,33 @@
1
1
  import { $module } from "@alepha/core";
2
2
  import { AlephaReact } from "@alepha/react";
3
+ import type { ControlProps } from "./components/Control.tsx";
4
+ import { RootRouter } from "./RootRouter.ts";
5
+ import { ToastService } from "./services/ToastService.tsx";
3
6
 
4
7
  // ---------------------------------------------------------------------------------------------------------------------
5
- import "@mantine/core/styles.css";
6
- import "@mantine/nprogress/styles.css";
7
- import "@mantine/spotlight/styles.css";
8
- import "@mantine/notifications/styles.css";
8
+
9
+ export { Flex } from "@mantine/core";
10
+ export { default as Action } from "./components/Action.tsx";
11
+ export { default as AlephaMantineProvider } from "./components/AlephaMantineProvider.tsx";
12
+ export { default as Control } from "./components/Control.tsx";
13
+ export { default as ControlDate } from "./components/ControlDate.tsx";
14
+ export { default as ControlSelect } from "./components/ControlSelect.tsx";
15
+ export { default as DarkModeButton } from "./components/DarkModeButton.tsx";
16
+ export { default as Omnibar } from "./components/Omnibar.tsx";
17
+ export { default as TypeForm } from "./components/TypeForm.tsx";
18
+ export { useToast } from "./hooks/useToast.ts";
19
+ export * from "./RootRouter.ts";
20
+ export { ToastService } from "./services/ToastService.tsx";
21
+ export * from "./utils/icons.tsx";
22
+ export * from "./utils/string.ts";
9
23
 
10
24
  // ---------------------------------------------------------------------------------------------------------------------
11
25
 
12
- export { default as Action } from "./components/Action";
13
- export { default as AlephaMantineProvider } from "./components/AlephaMantineProvider.tsx";
14
- export { default as Control } from "./components/Control";
26
+ declare module "typebox" {
27
+ interface TSchemaOptions {
28
+ $control?: Omit<ControlProps, "input">;
29
+ }
30
+ }
15
31
 
16
32
  // ---------------------------------------------------------------------------------------------------------------------
17
33
 
@@ -21,6 +37,6 @@ export { default as Control } from "./components/Control";
21
37
  * @module alepha.ui
22
38
  */
23
39
  export const AlephaUI = $module({
24
- name: "alepha.ui",
25
- services: [AlephaReact],
40
+ name: "alepha.ui",
41
+ services: [AlephaReact, ToastService, RootRouter],
26
42
  });
@@ -0,0 +1,71 @@
1
+ import type { NotificationData } from "@mantine/notifications";
2
+ import { notifications } from "@mantine/notifications";
3
+ import {
4
+ IconAlertTriangle,
5
+ IconCheck,
6
+ IconInfoCircle,
7
+ IconX,
8
+ } from "@tabler/icons-react";
9
+
10
+ export interface ToastServiceOptions {
11
+ default?: Partial<NotificationData>;
12
+ }
13
+
14
+ export class ToastService {
15
+ protected readonly raw = notifications;
16
+
17
+ public readonly options: ToastServiceOptions = {
18
+ default: {
19
+ autoClose: 5000,
20
+ withCloseButton: true,
21
+ position: "top-center",
22
+ },
23
+ };
24
+
25
+ public show(options: NotificationData) {
26
+ notifications.show({
27
+ ...this.options.default,
28
+ ...options,
29
+ });
30
+ }
31
+
32
+ public info(options: Partial<NotificationData>) {
33
+ this.show({
34
+ color: "blue",
35
+ icon: <IconInfoCircle size={20} />,
36
+ title: "Info",
37
+ message: "Information notification",
38
+ ...options,
39
+ });
40
+ }
41
+
42
+ public success(options: Partial<NotificationData>) {
43
+ this.show({
44
+ color: "green",
45
+ icon: <IconCheck size={16} />,
46
+ title: "Success",
47
+ message: "Operation completed successfully",
48
+ ...options,
49
+ });
50
+ }
51
+
52
+ public warning(options: Partial<NotificationData>) {
53
+ this.show({
54
+ color: "yellow",
55
+ icon: <IconAlertTriangle size={20} />,
56
+ title: "Warning",
57
+ message: "Please review this warning",
58
+ ...options,
59
+ });
60
+ }
61
+
62
+ public danger(options: Partial<NotificationData>) {
63
+ this.show({
64
+ color: "red",
65
+ icon: <IconX size={20} />,
66
+ title: "Error",
67
+ message: "An error occurred",
68
+ ...options,
69
+ });
70
+ }
71
+ }