@alepha/ui 0.10.6 → 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.
@@ -0,0 +1,77 @@
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
+ // biome-ignore lint/correctness/noUnusedImports: required
11
+ import React, { type ReactNode } from "react";
12
+
13
+ export interface OmnibarProps {
14
+ actions?: SpotlightActionData[];
15
+ shortcut?: string | string[];
16
+ searchPlaceholder?: string;
17
+ nothingFound?: ReactNode;
18
+ }
19
+
20
+ const defaultActions: SpotlightActionData[] = [
21
+ {
22
+ id: "home",
23
+ label: "Home",
24
+ description: "Go to home page",
25
+ onClick: () => console.log("Home"),
26
+ leftSection: <IconHome size={20} />,
27
+ },
28
+ {
29
+ id: "dashboard",
30
+ label: "Dashboard",
31
+ description: "View your dashboard",
32
+ onClick: () => console.log("Dashboard"),
33
+ leftSection: <IconDashboard size={20} />,
34
+ },
35
+ {
36
+ id: "documents",
37
+ label: "Documents",
38
+ description: "Browse all documents",
39
+ onClick: () => console.log("Documents"),
40
+ leftSection: <IconFileText size={20} />,
41
+ },
42
+ {
43
+ id: "profile",
44
+ label: "Profile",
45
+ description: "View and edit your profile",
46
+ onClick: () => console.log("Profile"),
47
+ leftSection: <IconUser size={20} />,
48
+ },
49
+ {
50
+ id: "settings",
51
+ label: "Settings",
52
+ description: "Manage application settings",
53
+ onClick: () => console.log("Settings"),
54
+ leftSection: <IconSettings size={20} />,
55
+ },
56
+ ];
57
+
58
+ const Omnibar = (props: OmnibarProps) => {
59
+ const actions = props.actions ?? defaultActions;
60
+ const shortcut = props.shortcut ?? "mod+K";
61
+ const searchPlaceholder = props.searchPlaceholder ?? "Search...";
62
+ const nothingFound = props.nothingFound ?? "Nothing found...";
63
+
64
+ return (
65
+ <Spotlight
66
+ actions={actions}
67
+ shortcut={shortcut}
68
+ searchProps={{
69
+ leftSection: <IconSearch size={20} />,
70
+ placeholder: searchPlaceholder,
71
+ }}
72
+ nothingFound={nothingFound}
73
+ />
74
+ );
75
+ };
76
+
77
+ export default Omnibar;
@@ -0,0 +1,217 @@
1
+ /* Sidebar Component Styles */
2
+
3
+ .alepha-sidebar {
4
+ width: 100%;
5
+ padding: 8px;
6
+ }
7
+
8
+ /* Search button styles */
9
+ .alepha-sidebar-search-button {
10
+ display: block;
11
+ width: 100%;
12
+ padding: 8px 12px;
13
+ margin-bottom: 12px;
14
+ background-color: var(--alepha-surface);
15
+ border: 1px solid var(--alepha-border);
16
+ border-radius: 8px;
17
+ transition: all 150ms ease;
18
+ cursor: pointer;
19
+ text-decoration: none;
20
+ }
21
+
22
+ .alepha-sidebar-search-button:hover {
23
+ background-color: var(--alepha-elevated-hover);
24
+ border-color: var(--mantine-color-blue-6);
25
+ }
26
+
27
+ .alepha-sidebar-search-button-content {
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: space-between;
31
+ width: 100%;
32
+ }
33
+
34
+ .alepha-sidebar-search-button-left {
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 10px;
38
+ color: var(--alepha-text-muted);
39
+ font-size: 14px;
40
+ }
41
+
42
+ .alepha-sidebar-search-button-shortcut {
43
+ display: flex;
44
+ align-items: center;
45
+ gap: 2px;
46
+ }
47
+
48
+ .alepha-sidebar-search-button-shortcut kbd {
49
+ padding: 2px 6px;
50
+ background-color: var(--alepha-elevated);
51
+ border: 1px solid var(--alepha-border);
52
+ border-radius: 4px;
53
+ font-size: 11px;
54
+ font-family: inherit;
55
+ color: var(--alepha-text-muted);
56
+ box-shadow: 0 1px 2px var(--alepha-shadow);
57
+ }
58
+
59
+ .alepha-sidebar-item-wrapper {
60
+ position: relative;
61
+ }
62
+
63
+ .alepha-sidebar-item {
64
+ display: block;
65
+ width: 100%;
66
+ padding: 8px 12px;
67
+ text-decoration: none;
68
+ color: var(--alepha-text);
69
+ border-radius: 24px;
70
+ transition: background-color 150ms ease;
71
+ cursor: pointer;
72
+ font-size: 14px;
73
+ line-height: 1.5;
74
+ }
75
+
76
+ /* Full width for level 0 items */
77
+ .alepha-sidebar-level-0 {
78
+ margin-left: 0;
79
+ }
80
+
81
+ /* Adjusted width for indented items */
82
+ .alepha-sidebar-level-1 {
83
+ width: calc(100% - 24px);
84
+ }
85
+
86
+ .alepha-sidebar-level-2 {
87
+ width: calc(100% - 48px);
88
+ }
89
+
90
+ /* Active state for current page */
91
+ .alepha-sidebar-item-active {
92
+ background-color: var(--mantine-color-blue-0);
93
+ color: var(--mantine-color-blue-7);
94
+ font-weight: 500;
95
+ }
96
+
97
+ .alepha-sidebar-item:hover:not(.alepha-sidebar-item-active) {
98
+ background-color: var(--alepha-elevated-hover);
99
+ color: var(--alepha-text) !important;
100
+ }
101
+
102
+ [data-mantine-color-scheme="dark"] .alepha-sidebar-item-active {
103
+ background-color: rgba(34, 139, 230, 0.15);
104
+ color: var(--mantine-color-blue-4);
105
+ }
106
+
107
+ .alepha-sidebar-item-active .alepha-sidebar-item-icon {
108
+ color: var(--mantine-color-blue-6);
109
+ }
110
+
111
+ [data-mantine-color-scheme="dark"]
112
+ .alepha-sidebar-item-active
113
+ .alepha-sidebar-item-icon {
114
+ color: var(--mantine-color-blue-4);
115
+ }
116
+
117
+ .alepha-sidebar-item-content {
118
+ flex: 1;
119
+ }
120
+
121
+ .alepha-sidebar-item-icon {
122
+ flex-shrink: 0;
123
+ color: var(--alepha-text-muted);
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: center;
127
+ }
128
+
129
+ .alepha-sidebar-item-label {
130
+ flex: 1;
131
+ font-weight: 400;
132
+ }
133
+
134
+ .alepha-sidebar-item-caret {
135
+ flex-shrink: 0;
136
+ color: var(--alepha-text-muted);
137
+ transition: transform 150ms ease;
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: center;
141
+ }
142
+
143
+ /* Level-specific indentation (now in the width calc above) */
144
+ .alepha-sidebar-level-1 {
145
+ margin-left: 24px;
146
+ }
147
+
148
+ .alepha-sidebar-level-2 {
149
+ margin-left: 48px;
150
+ }
151
+
152
+ /* Children container */
153
+ .alepha-sidebar-children {
154
+ position: relative;
155
+ margin-top: 2px;
156
+ }
157
+
158
+ .alepha-sidebar-children-items {
159
+ position: relative;
160
+ }
161
+
162
+ /* Vertical bar for nested items */
163
+ .alepha-sidebar-vertical-bar {
164
+ position: absolute;
165
+ top: 0;
166
+ bottom: 0;
167
+ width: 1px;
168
+ background: linear-gradient(
169
+ to bottom,
170
+ var(--alepha-border) 0%,
171
+ var(--alepha-border) 90%,
172
+ transparent 100%
173
+ );
174
+ opacity: 0.5;
175
+ }
176
+
177
+ /* Vertical bar positioning based on parent level */
178
+ .alepha-sidebar-children[data-parent-level="0"] .alepha-sidebar-vertical-bar {
179
+ left: 20px; /* Position for level 1 children (level 2 items) */
180
+ }
181
+
182
+ .alepha-sidebar-children[data-parent-level="1"] .alepha-sidebar-vertical-bar {
183
+ left: 45px; /* Position for level 2 children (level 3 items) */
184
+ }
185
+
186
+ /* Smooth animations */
187
+ .alepha-sidebar-children {
188
+ animation: alepha-sidebar-slideDown 150ms ease-out;
189
+ }
190
+
191
+ @keyframes alepha-sidebar-slideDown {
192
+ from {
193
+ opacity: 0;
194
+ transform: translateY(-4px);
195
+ }
196
+ to {
197
+ opacity: 1;
198
+ transform: translateY(0);
199
+ }
200
+ }
201
+
202
+ /* Remove default focus outline and add custom */
203
+ .alepha-sidebar-item:focus-visible {
204
+ outline: none;
205
+ box-shadow: 0 0 0 2px var(--alepha-focus-ring, #0969da);
206
+ }
207
+
208
+ /* Active state */
209
+ .alepha-sidebar-item:active {
210
+ transform: scale(0.98);
211
+ }
212
+
213
+ /* Optional: Add subtle hover effect for icons (but not for active items) */
214
+ .alepha-sidebar-item:hover:not(.alepha-sidebar-item-active)
215
+ .alepha-sidebar-item-icon {
216
+ color: var(--alepha-text);
217
+ }
@@ -0,0 +1,255 @@
1
+ import { useActive } from "@alepha/react/src/hooks/useActive";
2
+ import { Box, Flex, UnstyledButton } from "@mantine/core";
3
+ import {
4
+ IconChevronDown,
5
+ IconChevronRight,
6
+ IconCircle,
7
+ IconSearch,
8
+ } from "@tabler/icons-react";
9
+ import { useState } from "react";
10
+
11
+ export interface MenuItem {
12
+ id: string;
13
+ label: string;
14
+ icon?: React.ReactNode;
15
+ href?: string;
16
+ activeStartsWith?: boolean; // Use startWith matching for active state
17
+ onClick?: () => void;
18
+ children?: MenuItem[];
19
+ }
20
+
21
+ export interface SidebarProps {
22
+ menu: MenuItem[];
23
+ defaultOpenIds?: string[];
24
+ onItemClick?: (item: MenuItem) => void;
25
+ showSearchButton?: boolean;
26
+ onSearchClick?: () => void;
27
+ }
28
+
29
+ export const Sidebar: React.FC<SidebarProps> = ({
30
+ menu,
31
+ defaultOpenIds = [],
32
+ onItemClick,
33
+ showSearchButton = false,
34
+ onSearchClick,
35
+ }) => {
36
+ const [openIds, setOpenIds] = useState<Set<string>>(new Set(defaultOpenIds));
37
+
38
+ const toggleOpen = (id: string) => {
39
+ setOpenIds((prev) => {
40
+ const newSet = new Set(prev);
41
+ if (newSet.has(id)) {
42
+ newSet.delete(id);
43
+ } else {
44
+ newSet.add(id);
45
+ }
46
+ return newSet;
47
+ });
48
+ };
49
+
50
+ return (
51
+ <Box component="nav" className="alepha-sidebar">
52
+ {showSearchButton && (
53
+ <UnstyledButton
54
+ className="alepha-sidebar-search-button"
55
+ onClick={onSearchClick}
56
+ >
57
+ <Box className="alepha-sidebar-search-button-content">
58
+ <Box className="alepha-sidebar-search-button-left">
59
+ <IconSearch size={16} />
60
+ <span>Search...</span>
61
+ </Box>
62
+ <Box className="alepha-sidebar-search-button-shortcut">
63
+ <kbd>⌘+K</kbd>
64
+ </Box>
65
+ </Box>
66
+ </UnstyledButton>
67
+ )}
68
+ {menu.map((item) => (
69
+ <SidebarItem
70
+ key={item.id}
71
+ item={item}
72
+ level={0}
73
+ openIds={openIds}
74
+ onToggle={toggleOpen}
75
+ onItemClick={onItemClick}
76
+ />
77
+ ))}
78
+ </Box>
79
+ );
80
+ };
81
+
82
+ // ---------------------------------------------------------------------------------------------------------------------
83
+ // SidebarItem - Main component that decides which variant to render
84
+ // ---------------------------------------------------------------------------------------------------------------------
85
+
86
+ export interface SidebarItemProps {
87
+ item: MenuItem;
88
+ level: number;
89
+ openIds: Set<string>;
90
+ onToggle: (id: string) => void;
91
+ onItemClick?: (item: MenuItem) => void;
92
+ }
93
+
94
+ export const SidebarItem: React.FC<SidebarItemProps> = (props) => {
95
+ const { item, level } = props;
96
+ const maxLevel = 2; // 0, 1, 2 = 3 levels total
97
+
98
+ if (level > maxLevel) return null;
99
+
100
+ // Render different components based on whether item has href or not
101
+ if (item.href) {
102
+ return <SidebarItemHref {...props} />;
103
+ }
104
+
105
+ return <SidebarItemButton {...props} />;
106
+ };
107
+
108
+ // ---------------------------------------------------------------------------------------------------------------------
109
+ // SidebarItemHref - Component for items with href (navigation)
110
+ // ---------------------------------------------------------------------------------------------------------------------
111
+
112
+ const SidebarItemHref: React.FC<SidebarItemProps> = ({
113
+ item,
114
+ level,
115
+ openIds,
116
+ onToggle,
117
+ onItemClick,
118
+ }) => {
119
+ const hasChildren = item.children && item.children.length > 0;
120
+ const isOpen = openIds.has(item.id);
121
+
122
+ // Use the useActive hook for navigation
123
+ const { isActive, anchorProps } = useActive({
124
+ href: item.href!,
125
+ startWith: item.activeStartsWith,
126
+ });
127
+
128
+ const handleItemClick = (e: React.MouseEvent) => {
129
+ if (hasChildren) {
130
+ e.preventDefault();
131
+ onToggle(item.id);
132
+ }
133
+ // anchorProps.onClick handles navigation automatically
134
+ };
135
+
136
+ return (
137
+ <Box className="alepha-sidebar-item-wrapper">
138
+ <UnstyledButton
139
+ component="a"
140
+ {...anchorProps}
141
+ className={`alepha-sidebar-item alepha-sidebar-level-${level} ${isActive ? "alepha-sidebar-item-active" : ""}`}
142
+ onClick={hasChildren ? handleItemClick : anchorProps.onClick}
143
+ >
144
+ <Flex justify="space-between" align="center" w="100%">
145
+ <Flex className="alepha-sidebar-item-content" align="center" gap={10}>
146
+ <Box className="alepha-sidebar-item-icon">
147
+ {item.icon || <IconCircle size={16} />}
148
+ </Box>
149
+ <Box className="alepha-sidebar-item-label">{item.label}</Box>
150
+ </Flex>
151
+ {hasChildren && (
152
+ <Box className="alepha-sidebar-item-caret">
153
+ {isOpen ? (
154
+ <IconChevronDown size={14} />
155
+ ) : (
156
+ <IconChevronRight size={14} />
157
+ )}
158
+ </Box>
159
+ )}
160
+ </Flex>
161
+ </UnstyledButton>
162
+ {hasChildren && isOpen && (
163
+ <Box className="alepha-sidebar-children" data-parent-level={level}>
164
+ {(level === 0 || level === 1) && (
165
+ <Box className="alepha-sidebar-vertical-bar" />
166
+ )}
167
+ <Box className="alepha-sidebar-children-items">
168
+ {item.children!.map((child) => (
169
+ <SidebarItem
170
+ key={child.id}
171
+ item={child}
172
+ level={level + 1}
173
+ openIds={openIds}
174
+ onToggle={onToggle}
175
+ onItemClick={onItemClick}
176
+ />
177
+ ))}
178
+ </Box>
179
+ </Box>
180
+ )}
181
+ </Box>
182
+ );
183
+ };
184
+
185
+ // ---------------------------------------------------------------------------------------------------------------------
186
+ // SidebarItemButton - Component for items without href (buttons with onClick)
187
+ // ---------------------------------------------------------------------------------------------------------------------
188
+
189
+ const SidebarItemButton: React.FC<SidebarItemProps> = ({
190
+ item,
191
+ level,
192
+ openIds,
193
+ onToggle,
194
+ onItemClick,
195
+ }) => {
196
+ const hasChildren = item.children && item.children.length > 0;
197
+ const isOpen = openIds.has(item.id);
198
+
199
+ const handleItemClick = (e: React.MouseEvent) => {
200
+ e.preventDefault();
201
+ if (hasChildren) {
202
+ onToggle(item.id);
203
+ } else {
204
+ onItemClick?.(item);
205
+ item.onClick?.();
206
+ }
207
+ };
208
+
209
+ return (
210
+ <Box className="alepha-sidebar-item-wrapper">
211
+ <UnstyledButton
212
+ component="button"
213
+ className={`alepha-sidebar-item alepha-sidebar-level-${level}`}
214
+ onClick={handleItemClick}
215
+ >
216
+ <Flex justify="space-between" align="center" w="100%">
217
+ <Flex className="alepha-sidebar-item-content" align="center" gap={10}>
218
+ <Box className="alepha-sidebar-item-icon">
219
+ {item.icon || <IconCircle size={16} />}
220
+ </Box>
221
+ <Box className="alepha-sidebar-item-label">{item.label}</Box>
222
+ </Flex>
223
+ {hasChildren && (
224
+ <Box className="alepha-sidebar-item-caret">
225
+ {isOpen ? (
226
+ <IconChevronDown size={14} />
227
+ ) : (
228
+ <IconChevronRight size={14} />
229
+ )}
230
+ </Box>
231
+ )}
232
+ </Flex>
233
+ </UnstyledButton>
234
+ {hasChildren && isOpen && (
235
+ <Box className="alepha-sidebar-children" data-parent-level={level}>
236
+ {(level === 0 || level === 1) && (
237
+ <Box className="alepha-sidebar-vertical-bar" />
238
+ )}
239
+ <Box className="alepha-sidebar-children-items">
240
+ {item.children!.map((child) => (
241
+ <SidebarItem
242
+ key={child.id}
243
+ item={child}
244
+ level={level + 1}
245
+ openIds={openIds}
246
+ onToggle={onToggle}
247
+ onItemClick={onItemClick}
248
+ />
249
+ ))}
250
+ </Box>
251
+ </Box>
252
+ )}
253
+ </Box>
254
+ );
255
+ };
@@ -0,0 +1,158 @@
1
+ import type { TObject } from "@alepha/core";
2
+ import type { FormModel } from "@alepha/react-form";
3
+ import { Flex, Grid } 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
+ resetButtonProps?: Partial<Omit<ActionSubmitProps, "form">>;
26
+ }
27
+
28
+ /**
29
+ * TypeForm component that automatically renders all form inputs based on schema.
30
+ * Uses the Control component to render individual fields and Mantine Grid for responsive layout.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * import { t } from "alepha";
35
+ * import { useForm } from "@alepha/react-form";
36
+ * import { TypeForm } from "@alepha/ui";
37
+ *
38
+ * const form = useForm({
39
+ * schema: t.object({
40
+ * username: t.text(),
41
+ * email: t.text(),
42
+ * age: t.integer(),
43
+ * subscribe: t.boolean(),
44
+ * }),
45
+ * handler: (values) => {
46
+ * console.log(values);
47
+ * },
48
+ * });
49
+ *
50
+ * return <TypeForm form={form} columns={2} />;
51
+ * ```
52
+ */
53
+ const TypeForm = <T extends TObject>(props: TypeFormProps<T>) => {
54
+ const {
55
+ form,
56
+ columns = 3,
57
+ children,
58
+ controlProps,
59
+ skipFormElement = false,
60
+ skipSubmitButton = false,
61
+ submitButtonProps,
62
+ } = props;
63
+
64
+ if (!form.options?.schema?.properties) {
65
+ return null;
66
+ }
67
+
68
+ const fieldNames = Object.keys(form.options.schema.properties);
69
+
70
+ // Filter out unsupported field types (objects only, arrays are now supported)
71
+ const supportedFields = fieldNames.filter((fieldName) => {
72
+ const field = form.input[fieldName as keyof typeof form.input];
73
+ if (!field || typeof field !== "object" || !("schema" in field)) {
74
+ return false;
75
+ }
76
+
77
+ const schema: any = field.schema;
78
+
79
+ // Skip if it's an object (not supported by Control)
80
+ // Arrays are now supported via ControlSelect (MultiSelect/TagsInput)
81
+ if ("type" in schema) {
82
+ if (schema.type === "object") {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ // Check if it has properties (nested object)
88
+ if ("properties" in schema && schema.properties) {
89
+ return false;
90
+ }
91
+
92
+ return true;
93
+ });
94
+
95
+ // Handle column configuration with defaults: xs=1, sm=2, lg=3
96
+ const colSpan =
97
+ typeof columns === "number"
98
+ ? {
99
+ xs: 12,
100
+ sm: 6,
101
+ lg: 12 / columns,
102
+ }
103
+ : {
104
+ base: columns.base ? 12 / columns.base : undefined,
105
+ xs: columns.xs ? 12 / columns.xs : 12,
106
+ sm: columns.sm ? 12 / columns.sm : 6,
107
+ md: columns.md ? 12 / columns.md : undefined,
108
+ lg: columns.lg ? 12 / columns.lg : 4,
109
+ xl: columns.xl ? 12 / columns.xl : undefined,
110
+ };
111
+
112
+ const renderFields = () => {
113
+ if (children) {
114
+ return <>{children(form.input)}</>;
115
+ }
116
+
117
+ return (
118
+ <Grid>
119
+ {supportedFields.map((fieldName) => {
120
+ const field = form.input[fieldName as keyof typeof form.input];
121
+
122
+ // Type guard to ensure field has the expected structure
123
+ if (!field || typeof field !== "object" || !("schema" in field)) {
124
+ return null;
125
+ }
126
+
127
+ return (
128
+ <Grid.Col key={fieldName} span={colSpan}>
129
+ <Control input={field as any} {...controlProps} />
130
+ </Grid.Col>
131
+ );
132
+ })}
133
+ </Grid>
134
+ );
135
+ };
136
+
137
+ const content = (
138
+ <Flex direction={"column"} gap={"sm"}>
139
+ {renderFields()}
140
+ {!skipSubmitButton && (
141
+ <Flex>
142
+ <Action form={form} {...submitButtonProps}>
143
+ {submitButtonProps?.children ?? "Submit"}
144
+ </Action>
145
+ <button type={"reset"}>Reset</button>
146
+ </Flex>
147
+ )}
148
+ </Flex>
149
+ );
150
+
151
+ if (skipFormElement) {
152
+ return content;
153
+ }
154
+
155
+ return <form {...form.props}>{content}</form>;
156
+ };
157
+
158
+ export default TypeForm;