devnonla-ui 0.1.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.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/package.json +66 -0
  4. package/src/alert/Alert.tsx +109 -0
  5. package/src/app/App.tsx +36 -0
  6. package/src/button/Button.tsx +278 -0
  7. package/src/button/ButtonCopy.tsx +60 -0
  8. package/src/calendar/Calendar.tsx +133 -0
  9. package/src/chat/AgentPanel.tsx +203 -0
  10. package/src/chat/common/bgTasks.ts +41 -0
  11. package/src/chat/common/sse.ts +156 -0
  12. package/src/chat/common/types.ts +43 -0
  13. package/src/chat/common/useAgentStream.ts +351 -0
  14. package/src/chat/common/utils.ts +73 -0
  15. package/src/chat/index.ts +56 -0
  16. package/src/chat/message-ui/ChatAgentMessage.tsx +29 -0
  17. package/src/chat/message-ui/ChatError.tsx +24 -0
  18. package/src/chat/message-ui/ChatInput.tsx +162 -0
  19. package/src/chat/message-ui/ChatMarkdown.tsx +128 -0
  20. package/src/chat/message-ui/ChatMarkdownTable.tsx +45 -0
  21. package/src/chat/message-ui/ChatSpinner.tsx +5 -0
  22. package/src/chat/message-ui/ChatThinking.tsx +61 -0
  23. package/src/chat/message-ui/ChatUserMessage.tsx +72 -0
  24. package/src/chat/message-ui/ChatWelcome.tsx +70 -0
  25. package/src/chat/message-ui/MermaidBlock.tsx +266 -0
  26. package/src/chat/message-ui/mermaidFence.ts +55 -0
  27. package/src/chat/message-ui/sanitizeMermaid.ts +135 -0
  28. package/src/chat/tool-ui/BackgroundTaskToolUI.tsx +36 -0
  29. package/src/chat/tool-ui/BackgroundTasksBar.tsx +152 -0
  30. package/src/chat/tool-ui/CallAgentToolUI.tsx +168 -0
  31. package/src/chat/tool-ui/ChatToolCall.tsx +77 -0
  32. package/src/chat/tool-ui/GetCurrentTimeToolUI.tsx +49 -0
  33. package/src/chat/tool-ui/ReadSkillToolUI.tsx +80 -0
  34. package/src/chat/tool-ui/RunJsToolUI.tsx +106 -0
  35. package/src/chat/tool-ui/ToolUiBadge.tsx +10 -0
  36. package/src/chat/tool-ui/ToolUiTrailing.tsx +24 -0
  37. package/src/chat/tool-ui/WebFetchToolUI.tsx +53 -0
  38. package/src/chat/tool-ui/registry.ts +29 -0
  39. package/src/chat/tool-ui/types.ts +22 -0
  40. package/src/checkbox/Checkbox.tsx +50 -0
  41. package/src/codeblock/CodeBlock.tsx +76 -0
  42. package/src/codeblock/highlight.ts +159 -0
  43. package/src/colorpicker/ColorPicker.tsx +45 -0
  44. package/src/datepicker/DatePicker.tsx +481 -0
  45. package/src/desktop/DesktopHeader.tsx +92 -0
  46. package/src/desktop/DesktopIcon.tsx +34 -0
  47. package/src/desktop/DesktopStage.tsx +6 -0
  48. package/src/desktop/DesktopWindow.tsx +504 -0
  49. package/src/desktop/MeadowDesktop.tsx +15 -0
  50. package/src/desktop/MeadowShell.tsx +12 -0
  51. package/src/desktop/MeadowWallpaper.tsx +5 -0
  52. package/src/desktop/assets/desktop-meadow.jpg +0 -0
  53. package/src/desktop/assets.d.ts +9 -0
  54. package/src/desktop/surface.ts +1 -0
  55. package/src/drawer/Drawer.tsx +125 -0
  56. package/src/dropdown/ContextMenu.tsx +97 -0
  57. package/src/dropdown/Dropdown.tsx +139 -0
  58. package/src/dropdown/menuClasses.ts +9 -0
  59. package/src/empty/Empty.tsx +47 -0
  60. package/src/form/Form.tsx +95 -0
  61. package/src/form/FormItem.tsx +145 -0
  62. package/src/form/common/context.ts +13 -0
  63. package/src/form/common/enum.ts +20 -0
  64. package/src/form/common/rules.ts +68 -0
  65. package/src/form/common/types.ts +169 -0
  66. package/src/form/index.ts +35 -0
  67. package/src/form/partials/FormItemHelps.tsx +31 -0
  68. package/src/form/variants/FieldCheckbox.tsx +20 -0
  69. package/src/form/variants/FieldColor.tsx +63 -0
  70. package/src/form/variants/FieldDateTime.tsx +25 -0
  71. package/src/form/variants/FieldHidden.tsx +9 -0
  72. package/src/form/variants/FieldInput.tsx +24 -0
  73. package/src/form/variants/FieldNumber.tsx +28 -0
  74. package/src/form/variants/FieldObject.tsx +29 -0
  75. package/src/form/variants/FieldRadio.tsx +40 -0
  76. package/src/form/variants/FieldRepeater.tsx +62 -0
  77. package/src/form/variants/FieldRepeaterItem.tsx +64 -0
  78. package/src/form/variants/FieldSelect.tsx +27 -0
  79. package/src/form/variants/FieldSelectMultiple.tsx +32 -0
  80. package/src/form/variants/FieldSelectRemote.tsx +66 -0
  81. package/src/form/variants/FieldSwitch.tsx +17 -0
  82. package/src/form/variants/FieldTextarea.tsx +25 -0
  83. package/src/form/variants/FieldTime.tsx +25 -0
  84. package/src/form-layout/FormLayout.tsx +74 -0
  85. package/src/icon/FluentIcon.tsx +28 -0
  86. package/src/icon/fluent.ts +75 -0
  87. package/src/index.ts +214 -0
  88. package/src/input/Input.tsx +371 -0
  89. package/src/input/SearchInput.tsx +73 -0
  90. package/src/lib/cn.ts +6 -0
  91. package/src/lib/placement.ts +44 -0
  92. package/src/lib/sizes.ts +117 -0
  93. package/src/lib/surface.ts +8 -0
  94. package/src/menu/Menu.tsx +98 -0
  95. package/src/message/message.tsx +214 -0
  96. package/src/modal/Modal.tsx +277 -0
  97. package/src/pagination/Pagination.tsx +103 -0
  98. package/src/popconfirm/Popconfirm.tsx +134 -0
  99. package/src/popover/Popover.tsx +116 -0
  100. package/src/scroll/OverlayScroll.tsx +141 -0
  101. package/src/segmented/Segmented.tsx +67 -0
  102. package/src/select/Select.tsx +319 -0
  103. package/src/skeleton/Skeleton.tsx +47 -0
  104. package/src/spin/Spin.tsx +160 -0
  105. package/src/styles.css +897 -0
  106. package/src/switch/Switch.tsx +58 -0
  107. package/src/table/Table.tsx +387 -0
  108. package/src/tag/Tag.tsx +109 -0
  109. package/src/theme.ts +131 -0
  110. package/src/timepicker/TimePicker.tsx +751 -0
  111. package/src/tooltip/Tooltip.tsx +59 -0
@@ -0,0 +1,97 @@
1
+ import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import type { MenuItemType, MenuProps } from "./Dropdown";
5
+ import { menuContentClass, menuIconClass, menuItemClass } from "./menuClasses";
6
+
7
+ export type ContextMenuProps = {
8
+ menu?: MenuProps;
9
+ children: ReactNode;
10
+ open?: boolean;
11
+ onOpenChange?: (open: boolean) => void;
12
+ className?: string;
13
+ overlayClassName?: string;
14
+ disabled?: boolean;
15
+ };
16
+
17
+ function ChevronRight() {
18
+ return (
19
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden className="ml-auto opacity-50">
20
+ <path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
21
+ </svg>
22
+ );
23
+ }
24
+
25
+ function itemKey(item: MenuItemType, i: number) {
26
+ return item.key ?? (item.type === "divider" ? `divider-${i}` : `item-${i}`);
27
+ }
28
+
29
+ function MenuItems({
30
+ items,
31
+ onClick,
32
+ contentClassName,
33
+ }: {
34
+ items: (MenuItemType | null | undefined)[];
35
+ onClick?: MenuProps["onClick"];
36
+ contentClassName: string;
37
+ }) {
38
+ return (
39
+ <>
40
+ {items.map((item, i) => {
41
+ if (!item) return null;
42
+ const key = itemKey(item, i);
43
+ if (item.type === "divider") {
44
+ return <ContextMenuPrimitive.Separator key={key} className="my-1 h-px bg-border" />;
45
+ }
46
+ if (item.children?.length) {
47
+ return (
48
+ <ContextMenuPrimitive.Sub key={key}>
49
+ <ContextMenuPrimitive.SubTrigger disabled={item.disabled} className={cn(menuItemClass, "data-[state=open]:bg-foreground/8", item.danger && "text-destructive", item.className)} style={item.style}>
50
+ {item.icon ? <span className={menuIconClass}>{item.icon}</span> : null}
51
+ <span className="min-w-0 flex-1">{item.label}</span>
52
+ <ChevronRight />
53
+ </ContextMenuPrimitive.SubTrigger>
54
+ <ContextMenuPrimitive.Portal>
55
+ <ContextMenuPrimitive.SubContent sideOffset={4} className={contentClassName}>
56
+ <MenuItems items={item.children} onClick={onClick} contentClassName={contentClassName} />
57
+ </ContextMenuPrimitive.SubContent>
58
+ </ContextMenuPrimitive.Portal>
59
+ </ContextMenuPrimitive.Sub>
60
+ );
61
+ }
62
+ return (
63
+ <ContextMenuPrimitive.Item
64
+ key={key}
65
+ disabled={item.disabled}
66
+ className={cn(menuItemClass, item.danger && "text-destructive", item.className)}
67
+ style={item.style}
68
+ onSelect={() => {
69
+ item.onClick?.({ key });
70
+ onClick?.({ key });
71
+ }}
72
+ >
73
+ {item.icon ? <span className={menuIconClass}>{item.icon}</span> : null}
74
+ {item.label}
75
+ </ContextMenuPrimitive.Item>
76
+ );
77
+ })}
78
+ </>
79
+ );
80
+ }
81
+
82
+ export function ContextMenu({ menu, children, open, onOpenChange, className, overlayClassName, disabled }: ContextMenuProps) {
83
+ if (disabled) return children;
84
+
85
+ const contentClassName = cn(menuContentClass, className, overlayClassName, menu?.className);
86
+
87
+ return (
88
+ <ContextMenuPrimitive.Root modal open={open} onOpenChange={onOpenChange}>
89
+ <ContextMenuPrimitive.Trigger asChild>{children}</ContextMenuPrimitive.Trigger>
90
+ <ContextMenuPrimitive.Portal>
91
+ <ContextMenuPrimitive.Content collisionPadding={8} className={contentClassName} style={menu?.style}>
92
+ <MenuItems items={menu?.items ?? []} onClick={menu?.onClick} contentClassName={contentClassName} />
93
+ </ContextMenuPrimitive.Content>
94
+ </ContextMenuPrimitive.Portal>
95
+ </ContextMenuPrimitive.Root>
96
+ );
97
+ }
@@ -0,0 +1,139 @@
1
+ import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
2
+ import { type CSSProperties, type MouseEvent, type ReactNode, useState } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { type PopperPlacement, placementToRadix } from "../lib/placement";
5
+ import { menuContentClass, menuIconClass, menuItemClass } from "./menuClasses";
6
+
7
+ export type MenuItemType = {
8
+ key?: string;
9
+ label?: ReactNode;
10
+ icon?: ReactNode;
11
+ disabled?: boolean;
12
+ danger?: boolean;
13
+ type?: "item" | "divider";
14
+ children?: (MenuItemType | null | undefined)[];
15
+ onClick?: (info?: { key: string }) => void;
16
+ style?: CSSProperties;
17
+ className?: string;
18
+ };
19
+
20
+ export type MenuProps = {
21
+ items?: (MenuItemType | null | undefined)[];
22
+ onClick?: (info: { key: string }) => void;
23
+ style?: CSSProperties;
24
+ className?: string;
25
+ };
26
+
27
+ export type DropdownProps = {
28
+ menu?: MenuProps;
29
+ children: ReactNode;
30
+ trigger?: ("click" | "hover" | "contextMenu")[];
31
+ open?: boolean;
32
+ onOpenChange?: (open: boolean) => void;
33
+ placement?: PopperPlacement;
34
+ className?: string;
35
+ overlayClassName?: string;
36
+ disabled?: boolean;
37
+ /** antd 5.25+ alias — accepted, no-op (Radix unmounts when closed). */
38
+ destroyOnHidden?: boolean;
39
+ classNames?: { root?: string; overlay?: string };
40
+ };
41
+
42
+ function ChevronRight() {
43
+ return (
44
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden className="ml-auto opacity-50">
45
+ <path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
46
+ </svg>
47
+ );
48
+ }
49
+
50
+ function itemKey(item: MenuItemType, i: number) {
51
+ return item.key ?? (item.type === "divider" ? `divider-${i}` : `item-${i}`);
52
+ }
53
+
54
+ function MenuItems({ items, onClick }: { items: (MenuItemType | null | undefined)[]; onClick?: MenuProps["onClick"] }) {
55
+ return (
56
+ <>
57
+ {items.map((item, i) => {
58
+ if (!item) return null;
59
+ const key = itemKey(item, i);
60
+ if (item.type === "divider") {
61
+ return <DropdownMenu.Separator key={key} className="my-1 h-px bg-border" />;
62
+ }
63
+ if (item.children?.length) {
64
+ return (
65
+ <DropdownMenu.Sub key={key}>
66
+ <DropdownMenu.SubTrigger disabled={item.disabled} className={cn(menuItemClass, "data-[state=open]:bg-foreground/8", item.danger && "text-destructive", item.className)} style={item.style}>
67
+ {item.icon ? <span className={menuIconClass}>{item.icon}</span> : null}
68
+ <span className="min-w-0 flex-1">{item.label}</span>
69
+ <ChevronRight />
70
+ </DropdownMenu.SubTrigger>
71
+ <DropdownMenu.Portal>
72
+ <DropdownMenu.SubContent sideOffset={4} className={cn(menuContentClass, "nonla-popper")}>
73
+ <MenuItems items={item.children} onClick={onClick} />
74
+ </DropdownMenu.SubContent>
75
+ </DropdownMenu.Portal>
76
+ </DropdownMenu.Sub>
77
+ );
78
+ }
79
+ return (
80
+ <DropdownMenu.Item
81
+ key={key}
82
+ disabled={item.disabled}
83
+ className={cn(menuItemClass, item.danger && "text-destructive", item.className)}
84
+ style={item.style}
85
+ onSelect={() => {
86
+ item.onClick?.({ key });
87
+ onClick?.({ key });
88
+ }}
89
+ >
90
+ {item.icon ? <span className={menuIconClass}>{item.icon}</span> : null}
91
+ {item.label}
92
+ </DropdownMenu.Item>
93
+ );
94
+ })}
95
+ </>
96
+ );
97
+ }
98
+
99
+ export function Dropdown({ menu, children, trigger = ["click"], open, onOpenChange, placement = "bottomLeft", className, overlayClassName, disabled }: DropdownProps) {
100
+ const { side, align } = placementToRadix(placement);
101
+ const hover = trigger.includes("hover");
102
+ const contextMenu = trigger.includes("contextMenu");
103
+ const click = trigger.includes("click") || (!hover && !contextMenu);
104
+ const [innerOpen, setInnerOpen] = useState(false);
105
+ const isOpen = open ?? innerOpen;
106
+ const setIsOpen = (v: boolean) => {
107
+ if (open === undefined) setInnerOpen(v);
108
+ onOpenChange?.(v);
109
+ };
110
+
111
+ const handleContext = (e: MouseEvent) => {
112
+ if (!contextMenu || disabled) return;
113
+ e.preventDefault();
114
+ setIsOpen(true);
115
+ };
116
+
117
+ return (
118
+ <DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen} modal={!hover}>
119
+ <DropdownMenu.Trigger asChild disabled={disabled} onClick={click || contextMenu ? undefined : (e) => e.preventDefault()} onMouseEnter={hover && !disabled ? () => setIsOpen(true) : undefined} onMouseLeave={hover ? () => setIsOpen(false) : undefined} onContextMenu={handleContext}>
120
+ {children}
121
+ </DropdownMenu.Trigger>
122
+ <DropdownMenu.Portal>
123
+ <DropdownMenu.Content
124
+ side={side}
125
+ align={align === "center" ? "start" : align}
126
+ sideOffset={4}
127
+ className={cn(menuContentClass, "nonla-popper", className, overlayClassName, menu?.className)}
128
+ style={menu?.style}
129
+ onMouseEnter={hover ? () => setIsOpen(true) : undefined}
130
+ onMouseLeave={hover ? () => setIsOpen(false) : undefined}
131
+ >
132
+ <MenuItems items={menu?.items ?? []} onClick={menu?.onClick} />
133
+ </DropdownMenu.Content>
134
+ </DropdownMenu.Portal>
135
+ </DropdownMenu.Root>
136
+ );
137
+ }
138
+
139
+ export type { MenuProps as DropdownMenuProps };
@@ -0,0 +1,9 @@
1
+ import { cn } from "../lib/cn";
2
+ import { glassOverlayClass } from "../lib/surface";
3
+
4
+ export const menuItemClass =
5
+ "nonla-menu-item relative flex cursor-default select-none items-center gap-2 rounded-md border border-solid border-transparent px-2.5 py-1.5 text-sm outline-none data-[disabled]:opacity-40";
6
+
7
+ export const menuContentClass = cn(glassOverlayClass, "min-w-40 overflow-hidden p-1");
8
+
9
+ export const menuIconClass = "inline-flex size-4 shrink-0 items-center justify-center [&_img]:size-4 [&_svg]:size-4";
@@ -0,0 +1,47 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ const LOGO_CONE = "M768 68 C752 68 640 145 439 300 C338 377 250 449 194 500 C172 519 150 540 134 560 C122 576 109 591 103 600 C99 613 100 628 101 640 A666 108 0 0 1 1433 640 C1434 628 1435 613 1431 600 C1425 591 1412 576 1400 560 C1384 540 1362 519 1340 500 C1284 449 1196 377 1095 300 C894 145 782 68 768 68Z";
5
+
6
+ function LogoMark() {
7
+ return (
8
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="100 40 1336 960" width={48} height={35} fill="none" aria-hidden className="text-muted-foreground opacity-40">
9
+ <ellipse fill="color-mix(in srgb, currentColor 48%, black)" cx="767" cy="640" rx="666" ry="190" />
10
+ <circle fill="currentColor" cx="768" cy="602" r="371.6" />
11
+ <path fill="none" stroke="color-mix(in srgb, currentColor 78%, black)" strokeWidth="6" d="M112 640 A655 108 0 0 1 1422 640" />
12
+ <path fill="none" stroke="color-mix(in srgb, currentColor 48%, black)" strokeWidth="20" d="M112 640 A655 106 0 0 1 1422 640" />
13
+ <path fill="color-mix(in srgb, currentColor 78%, black)" d={LOGO_CONE} />
14
+ <g fill="none" stroke="color-mix(in srgb, currentColor 48%, black)" strokeWidth="6" strokeLinecap="round" opacity="0.55">
15
+ <path d="M750 96 C625 260 455 421 282 563" />
16
+ <path d="M758 96 C700 241 644 377 588 507" />
17
+ <path d="M778 96 C836 241 892 377 948 507" />
18
+ <path d="M786 96 C911 260 1081 421 1254 563" />
19
+ </g>
20
+ <g fill="var(--nonla-bg)">
21
+ <ellipse cx="607.5" cy="705" rx="44.5" ry="68" />
22
+ <ellipse cx="921.5" cy="705" rx="44.5" ry="68" />
23
+ </g>
24
+ <g fill="var(--nonla-solid-fg)">
25
+ <ellipse cx="618" cy="678" rx="13" ry="19" />
26
+ <ellipse cx="932" cy="678" rx="13" ry="19" />
27
+ </g>
28
+ <path fill="none" stroke="var(--nonla-bg)" strokeWidth="24" strokeLinecap="round" d="M696 802 C733 846 801 846 837 802" />
29
+ </svg>
30
+ );
31
+ }
32
+
33
+ export type EmptyProps = {
34
+ description?: ReactNode | null;
35
+ children?: ReactNode;
36
+ className?: string;
37
+ };
38
+
39
+ export function Empty({ description = "No data", children, className }: EmptyProps) {
40
+ return (
41
+ <div role="status" className={cn("flex min-w-0 flex-col items-center justify-center gap-3 px-6 py-10 text-center", className)}>
42
+ <LogoMark />
43
+ {description != null ? <p className="m-0 max-w-sm text-sm text-muted-foreground">{description}</p> : null}
44
+ {children ? <div className="mt-2 flex flex-col items-center gap-2">{children}</div> : null}
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,95 @@
1
+ import { useCallback, useEffect, useMemo, useRef } from "react";
2
+ import { FormProvider, type FieldValues, type UseFormReturn, useWatch } from "react-hook-form";
3
+ import { cn } from "../lib/cn";
4
+ import { FormExtraContext, type FormFetcher } from "./common/context";
5
+ import { fieldNameOf } from "./common/rules";
6
+ import type { TFormItemProps } from "./common/types";
7
+ import { FormItem } from "./FormItem";
8
+
9
+ export type FormProps<T extends FieldValues = FieldValues> = {
10
+ form: UseFormReturn<T>;
11
+ items: TFormItemProps[];
12
+ onValuesChange?: (allValues: T) => void;
13
+ /** Injected for `select_remote` — UI package never imports app API. */
14
+ fetcher?: FormFetcher;
15
+ className?: string;
16
+ /** Debounce ms for onValuesChange (default 300). */
17
+ valuesChangeDebounce?: number;
18
+ };
19
+
20
+ function evalCondition(fieldValue: unknown, operator: string, compareValue: unknown): boolean {
21
+ switch (operator) {
22
+ case "==":
23
+ // eslint-disable-next-line eqeqeq
24
+ return fieldValue == compareValue;
25
+ case "!=":
26
+ // eslint-disable-next-line eqeqeq
27
+ return fieldValue != compareValue;
28
+ case ">=":
29
+ return (fieldValue as number) >= (compareValue as number);
30
+ case "<=":
31
+ return (fieldValue as number) <= (compareValue as number);
32
+ case ">":
33
+ return (fieldValue as number) > (compareValue as number);
34
+ case "<":
35
+ return (fieldValue as number) < (compareValue as number);
36
+ default:
37
+ return false;
38
+ }
39
+ }
40
+
41
+ function matchConditions(form: UseFormReturn<FieldValues>, conditions?: TFormItemProps["conditions"]): boolean {
42
+ if (!conditions?.length) return true;
43
+ for (const group of conditions) {
44
+ let andOk = true;
45
+ for (const [fieldName, operator, compareValue] of group) {
46
+ const fieldValue = form.getValues(fieldName);
47
+ andOk = andOk && evalCondition(fieldValue, operator, compareValue);
48
+ }
49
+ if (andOk) return true;
50
+ }
51
+ return false;
52
+ }
53
+
54
+ export function Form<T extends FieldValues = FieldValues>({
55
+ form,
56
+ items,
57
+ onValuesChange,
58
+ fetcher,
59
+ className,
60
+ valuesChangeDebounce = 300,
61
+ }: FormProps<T>) {
62
+ const watchValues = useWatch({ control: form.control });
63
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
64
+ const onValuesChangeRef = useRef(onValuesChange);
65
+ onValuesChangeRef.current = onValuesChange;
66
+
67
+ useEffect(() => {
68
+ if (!onValuesChangeRef.current) return;
69
+ if (timerRef.current) clearTimeout(timerRef.current);
70
+ timerRef.current = setTimeout(() => {
71
+ onValuesChangeRef.current?.(form.getValues());
72
+ }, valuesChangeDebounce);
73
+ return () => {
74
+ if (timerRef.current) clearTimeout(timerRef.current);
75
+ };
76
+ }, [watchValues, form, valuesChangeDebounce]);
77
+
78
+ const extra = useMemo(() => ({ fetcher }), [fetcher]);
79
+
80
+ const renderItem = useCallback(
81
+ (item: TFormItemProps, index: number) => {
82
+ if (!matchConditions(form as UseFormReturn<FieldValues>, item.conditions)) return null;
83
+ return <FormItem key={`${fieldNameOf(item.name)}-${index}`} {...item} control={form.control as never} />;
84
+ },
85
+ [form],
86
+ );
87
+
88
+ return (
89
+ <FormExtraContext.Provider value={extra}>
90
+ <FormProvider {...form}>
91
+ <div className={cn("grid grid-cols-12 gap-x-4", className)}>{items.map((item, index) => renderItem(item, index))}</div>
92
+ </FormProvider>
93
+ </FormExtraContext.Provider>
94
+ );
95
+ }
@@ -0,0 +1,145 @@
1
+ import { useState, type ComponentType } from "react";
2
+ import { Controller, type Control, type FieldValues } from "react-hook-form";
3
+ import { cn } from "../lib/cn";
4
+ import { EFormItemType } from "./common/enum";
5
+ import { fieldNameOf, hydrateRules, isRuleRequired } from "./common/rules";
6
+ import type { TFormItemCustom, TFormItemProps } from "./common/types";
7
+ import { FormItemHelps } from "./partials/FormItemHelps";
8
+ import { FieldCheckbox } from "./variants/FieldCheckbox";
9
+ import { FieldColor } from "./variants/FieldColor";
10
+ import { FieldDateTime } from "./variants/FieldDateTime";
11
+ import { FieldHidden } from "./variants/FieldHidden";
12
+ import { FieldInput } from "./variants/FieldInput";
13
+ import { FieldNumber } from "./variants/FieldNumber";
14
+ import { FieldObject } from "./variants/FieldObject";
15
+ import { FieldRadio } from "./variants/FieldRadio";
16
+ import { FieldRepeater } from "./variants/FieldRepeater";
17
+ import { FieldSelect } from "./variants/FieldSelect";
18
+ import { FieldSelectMultiple } from "./variants/FieldSelectMultiple";
19
+ import { FieldSelectRemote } from "./variants/FieldSelectRemote";
20
+ import { FieldSwitch } from "./variants/FieldSwitch";
21
+ import { FieldTextarea } from "./variants/FieldTextarea";
22
+ import { FieldTime } from "./variants/FieldTime";
23
+
24
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+ const fieldComponents: Partial<Record<EFormItemType, ComponentType<any>>> = {
26
+ [EFormItemType.Input]: FieldInput,
27
+ [EFormItemType.Number]: FieldNumber,
28
+ [EFormItemType.Select]: FieldSelect,
29
+ [EFormItemType.SelectMultiple]: FieldSelectMultiple,
30
+ [EFormItemType.Hidden]: FieldHidden,
31
+ [EFormItemType.Repeater]: FieldRepeater,
32
+ [EFormItemType.SelectRemote]: FieldSelectRemote,
33
+ [EFormItemType.Radio]: FieldRadio,
34
+ [EFormItemType.Object]: FieldObject,
35
+ [EFormItemType.Textarea]: FieldTextarea,
36
+ [EFormItemType.Color]: FieldColor,
37
+ [EFormItemType.DateTime]: FieldDateTime,
38
+ [EFormItemType.Time]: FieldTime,
39
+ [EFormItemType.Switch]: FieldSwitch,
40
+ [EFormItemType.Checkbox]: FieldCheckbox,
41
+ [EFormItemType.JSON]: FieldTextarea,
42
+ [EFormItemType.Editor]: FieldTextarea,
43
+ };
44
+
45
+ export type FormItemProps = TFormItemProps & {
46
+ control: Control<FieldValues>;
47
+ };
48
+
49
+ function isCustomItem(props: TFormItemProps): props is TFormItemCustom {
50
+ return props.type === EFormItemType.Custom;
51
+ }
52
+
53
+ export function FormItem(props: FormItemProps) {
54
+ const {
55
+ label,
56
+ helps,
57
+ rules,
58
+ type,
59
+ collapsable = false,
60
+ className,
61
+ labelClassName,
62
+ innerClassName,
63
+ colSpan = 12,
64
+ control,
65
+ name,
66
+ } = props;
67
+
68
+ const [collapsed, setCollapsed] = useState(false);
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
+ const FieldComponent: ComponentType<any> | undefined = type ? fieldComponents[type] : FieldInput;
71
+ const fieldName = fieldNameOf(name);
72
+ const required = isRuleRequired(rules);
73
+ const rhfRules = hydrateRules(rules);
74
+
75
+ if (type === EFormItemType.Hidden) {
76
+ return FieldComponent ? (
77
+ <Controller
78
+ control={control}
79
+ name={fieldName}
80
+ rules={rhfRules}
81
+ render={({ field }) => <FieldComponent {...props} field={field} />}
82
+ />
83
+ ) : null;
84
+ }
85
+
86
+ return (
87
+ <Controller
88
+ control={control}
89
+ name={fieldName}
90
+ rules={rhfRules}
91
+ render={({ field, fieldState }) => (
92
+ <div className={cn("flex flex-col gap-1.5", className)} style={{ gridColumn: `span ${colSpan} / span ${colSpan}` }}>
93
+ {label != null && label !== "" ? (
94
+ <div className={cn("flex flex-row items-center gap-2", labelClassName)}>
95
+ {collapsable ? (
96
+ <button
97
+ type="button"
98
+ aria-label={collapsed ? "Expand" : "Collapse"}
99
+ className="inline-flex size-4 cursor-pointer items-center justify-center rounded-[3px] border-0 bg-muted p-0 text-muted-foreground"
100
+ onClick={() => setCollapsed((v) => !v)}
101
+ >
102
+ <svg
103
+ width="10"
104
+ height="10"
105
+ viewBox="0 0 12 12"
106
+ fill="none"
107
+ aria-hidden
108
+ className={cn("transition-transform", collapsed && "-rotate-90")}
109
+ >
110
+ <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
111
+ </svg>
112
+ </button>
113
+ ) : null}
114
+ <label className="text-sm text-foreground">
115
+ {label}
116
+ {required ? <span className="ml-1 text-destructive">*</span> : null}
117
+ </label>
118
+ </div>
119
+ ) : null}
120
+
121
+ <div
122
+ className={cn(
123
+ "flex flex-col transition-[max-height]",
124
+ collapsable && label ? "pl-6" : "",
125
+ collapsed ? "max-h-0 overflow-hidden" : "",
126
+ innerClassName,
127
+ )}
128
+ >
129
+ {isCustomItem(props) ? (
130
+ props.render({ field, fieldState, status: fieldState.error ? "error" : undefined })
131
+ ) : FieldComponent ? (
132
+ <FieldComponent {...props} field={field} control={control} status={fieldState.error ? "error" : undefined} />
133
+ ) : null}
134
+
135
+ <FormItemHelps items={helps} />
136
+
137
+ <div className="min-h-6 pt-1 text-xs leading-snug" role={fieldState.error?.message ? "alert" : undefined}>
138
+ {fieldState.error?.message ? <div className="pl-2.75 text-destructive">{fieldState.error.message}</div> : null}
139
+ </div>
140
+ </div>
141
+ </div>
142
+ )}
143
+ />
144
+ );
145
+ }
@@ -0,0 +1,13 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ export type FormFetcher = (endpoint: string) => Promise<unknown>;
4
+
5
+ export type FormExtraContextValue = {
6
+ fetcher?: FormFetcher;
7
+ };
8
+
9
+ export const FormExtraContext = createContext<FormExtraContextValue>({});
10
+
11
+ export function useFormExtra() {
12
+ return useContext(FormExtraContext);
13
+ }
@@ -0,0 +1,20 @@
1
+ export enum EFormItemType {
2
+ Textarea = "textarea",
3
+ Input = "input",
4
+ Number = "number",
5
+ Select = "select",
6
+ SelectMultiple = "select_multiple",
7
+ DateTime = "date_time",
8
+ Time = "time",
9
+ Repeater = "repeater",
10
+ Hidden = "hidden",
11
+ SelectRemote = "select_remote",
12
+ Radio = "radio",
13
+ JSON = "json",
14
+ Color = "color",
15
+ Object = "object",
16
+ Editor = "editor",
17
+ Switch = "switch",
18
+ Checkbox = "checkbox",
19
+ Custom = "custom",
20
+ }
@@ -0,0 +1,68 @@
1
+ import type { RegisterOptions } from "react-hook-form";
2
+ import type { TFormRule, TRuleValueMessage } from "./types";
3
+
4
+ function asValueMessage(raw: number | TRuleValueMessage | undefined): TRuleValueMessage | undefined {
5
+ if (raw == null) return undefined;
6
+ if (typeof raw === "number") return { value: raw };
7
+ return raw;
8
+ }
9
+
10
+ function compilePattern(value: string | RegExp, message?: string): { value: RegExp; message?: string } | undefined {
11
+ if (value instanceof RegExp) {
12
+ return { value, message };
13
+ }
14
+ try {
15
+ return { value: new RegExp(value), message };
16
+ } catch (err) {
17
+ console.error("[nonla-ui Form] Invalid pattern regex:", value, err);
18
+ return undefined;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Hydrate JSON-serializable `TFormRule` into RHF `RegisterOptions`.
24
+ * Converts string `pattern.value` → `RegExp`.
25
+ */
26
+ export function hydrateRules(rules?: TFormRule): RegisterOptions | undefined {
27
+ if (!rules) return undefined;
28
+
29
+ const out: RegisterOptions = {};
30
+
31
+ if (rules.required != null) {
32
+ out.required = rules.required;
33
+ }
34
+
35
+ const min = asValueMessage(rules.min);
36
+ if (min) out.min = min.message != null ? { value: min.value, message: min.message } : min.value;
37
+
38
+ const max = asValueMessage(rules.max);
39
+ if (max) out.max = max.message != null ? { value: max.value, message: max.message } : max.value;
40
+
41
+ const minLength = asValueMessage(rules.minLength);
42
+ if (minLength) out.minLength = minLength.message != null ? { value: minLength.value, message: minLength.message } : minLength.value;
43
+
44
+ const maxLength = asValueMessage(rules.maxLength);
45
+ if (maxLength) out.maxLength = maxLength.message != null ? { value: maxLength.value, message: maxLength.message } : maxLength.value;
46
+
47
+ if (rules.pattern) {
48
+ const compiled = compilePattern(rules.pattern.value, rules.pattern.message);
49
+ if (compiled) {
50
+ out.pattern = compiled.message != null ? { value: compiled.value, message: compiled.message } : compiled.value;
51
+ }
52
+ }
53
+
54
+ if (rules.validate) {
55
+ out.validate = rules.validate as RegisterOptions["validate"];
56
+ }
57
+
58
+ return out;
59
+ }
60
+
61
+ export function fieldNameOf(name: string | string[] | number[]): string {
62
+ return Array.isArray(name) ? name.join(".") : String(name);
63
+ }
64
+
65
+ export function isRuleRequired(rules?: TFormRule): boolean {
66
+ if (rules?.required == null || rules.required === false) return false;
67
+ return true;
68
+ }