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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 devlangla
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # devnonla-ui (NonlaUI)
2
+
3
+ Ant Design–like React controls for Nonla Agents.
4
+
5
+ ```bash
6
+ bun add devnonla-ui
7
+ ```
8
+
9
+ Peer deps: `react` / `react-dom` >= 19, `react-hook-form`. Consumer should use Tailwind CSS v4.
10
+
11
+ ```tsx
12
+ import "devnonla-ui/styles.css";
13
+ import { App, Button, EFormItemType, Form, Modal, message, type TFormItemProps } from "devnonla-ui";
14
+ import { useForm } from "react-hook-form";
15
+
16
+ message.success("Saved");
17
+ Modal.confirm({ title: "Delete?", onOk: async () => {} });
18
+
19
+ const items: TFormItemProps[] = [
20
+ {
21
+ type: EFormItemType.Input,
22
+ name: "name",
23
+ label: "Name",
24
+ rules: { required: "Name is required" },
25
+ options: { placeholder: "Your name" },
26
+ },
27
+ ];
28
+
29
+ function Example() {
30
+ const form = useForm({ defaultValues: { name: "" } });
31
+ return (
32
+ <App>
33
+ <form onSubmit={form.handleSubmit(console.log)}>
34
+ <Form form={form} items={items} />
35
+ <Button type="primary" htmlType="submit">
36
+ Save
37
+ </Button>
38
+ </form>
39
+ </App>
40
+ );
41
+ }
42
+ ```
43
+
44
+ `Form` is JSON-schema driven (ZoForm-style) on `react-hook-form`. The BE can return `TFormItemProps[]`; FE hydrates `rules.pattern` strings to `RegExp`. Pass `fetcher` for `select_remote` fields.
45
+
46
+ # Sizes
47
+
48
+ Only **3 canonical sizes**: `small` | `default` | `large`.
49
+
50
+ Edit [`src/lib/sizes.ts`](src/lib/sizes.ts) — `CONTROL_SIZES`.
51
+
52
+ Aliases: `xs` → small, `middle` / `medium` → default.
53
+
54
+ # Theme (colors)
55
+
56
+ All color lives in **`--nonla-*` knobs** (`src/styles.css`). Components never hardcode palette hex.
57
+
58
+ **Other apps** — import the CSS, then override knobs. Do not fork Button/Tag/….
59
+
60
+ ```css
61
+ @import "tailwindcss";
62
+ @import "devnonla-ui/styles.css";
63
+
64
+ :root {
65
+ --nonla-brand: #3b82f6;
66
+ --nonla-bg: #0b0f19;
67
+ }
68
+ ```
69
+
70
+ `--brand-50` … `--brand-800` follow `--nonla-brand` (500 = the knob). Text on cream uses `brand-700`.
71
+
72
+ Or at runtime:
73
+
74
+ ```tsx
75
+ <App
76
+ theme={{
77
+ colors: { brand: "#3b82f6", bg: "#0b0f19", border: "#666666", borderInput: "#8a8a8a" },
78
+ }}
79
+ >
80
+
81
+ </App>
82
+ ```
83
+
84
+ Flat knobs still work (`brand`, `colorBorder`, `colorBorderInput`, …). `applyNonlaTheme({ brand: "#3b82f6" })` does the same on `:root`.
85
+
86
+ Core knobs: `--nonla-bg`, `--nonla-fg`, `--nonla-brand`, `--nonla-danger`, `--nonla-success`, `--nonla-warn`, `--nonla-link`, `--nonla-radius` (small = −2px, large = +2px), `--nonla-height` / `--nonla-height-sm` / `--nonla-height-lg`, plus surfaces (`--nonla-surface`, `--nonla-chip`, …) and preset accents (`--nonla-blue`, `--nonla-purple`, …).
87
+
88
+ Those map into shadcn-standard tokens (`--background`, `--destructive`, …) plus Nonla aliases (`--brand`, `--success`, …).
89
+
90
+ - You: only touch `--nonla-*` (CSS or `theme` / `applyNonlaTheme`).
91
+ - Shadcn consumers: can still override `--background` / `--primary` / etc.
92
+ - Nonla CTA = `--brand` (from `--nonla-brand`). `--primary` = ink (emphasis), not the amber button.
93
+
94
+ # Desktop
95
+
96
+ Meadow wallpaper, window chrome, header bar, and desktop icons live in this package so other Nonla apps share the same shell.
97
+
98
+ ```tsx
99
+ import { DesktopStage, DesktopHeader, MeadowDesktop, DesktopWindow, MeadowShell, Menu, FluentIcon } from "devnonla-ui";
100
+
101
+ <DesktopStage>
102
+ <MeadowDesktop>{icons}</MeadowDesktop>
103
+ <DesktopHeader logo={logo} leading={nav} trailing={apps} profile={avatar} />
104
+ <DesktopWindow title="Tools" expanded={false} onClose={close} onToggleExpand={toggle}>
105
+ {children}
106
+ </DesktopWindow>
107
+ </DesktopStage>
108
+ ```
109
+
110
+ `MeadowShell` is the login/setup backdrop. `Menu` is the glass picker (trigger + items + hover action) used by AgentsMenu. Wallpaper defaults to the bundled meadow; pass `src` on `MeadowWallpaper` to swap.
111
+
112
+ # Chat
113
+
114
+ `AgentPanel` is the full chat surface: welcome, messages, thinking, tool calls, markdown, composer. Attach an SSE endpoint and send.
115
+
116
+ ```tsx
117
+ import { AgentPanel } from "devnonla-ui";
118
+
119
+ <AgentPanel
120
+ endpoint="/api/agents/abc/assistant/stream"
121
+ title="Nova"
122
+ extraBody={{ providerId, modelId: model }}
123
+ />
124
+ ```
125
+
126
+ `endpoint` can also be a function that returns a `Response` (tests, mocks).
127
+
128
+ POST body: `{ messages, ...extraBody }`. Stream events:
129
+
130
+ `text-delta` | `thinking-delta` | `tool-call` | `tool-result` | `done` | `error`
131
+
132
+ Primitives (`ChatWelcome`, `ChatUserMessage`, `ChatAgentMessage`, `ChatThinking`, `ChatToolCall`, `ChatInput`, `ChatError`, `ChatMarkdown`) stay exported for custom layouts.
133
+
134
+ # Icons
135
+
136
+ `FluentIcon` renders vendored Fluent Color (`@iconify-json/fluent-color`). No Solar / Lucide.
137
+
138
+ ```tsx
139
+ <FluentIcon name="settings-24" size={16} />
140
+ ```
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "devnonla-ui",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "NonlaUI — antd-like React controls for Nonla Agents",
6
+ "author": "devlangla <devlangla0x01@gmail.com>",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/devnonla/devnonla-ui.git"
11
+ },
12
+ "homepage": "https://github.com/devnonla/devnonla-ui",
13
+ "bugs": {
14
+ "url": "https://github.com/devnonla/devnonla-ui/issues"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "sideEffects": [
20
+ "**/*.css"
21
+ ],
22
+ "exports": {
23
+ ".": "./src/index.ts",
24
+ "./styles.css": "./src/styles.css"
25
+ },
26
+ "main": "./src/index.ts",
27
+ "types": "./src/index.ts",
28
+ "files": [
29
+ "src"
30
+ ],
31
+ "scripts": {
32
+ "typecheck": "tsc --noEmit"
33
+ },
34
+ "peerDependencies": {
35
+ "react": ">=19",
36
+ "react-dom": ">=19",
37
+ "react-hook-form": "^7.0.0"
38
+ },
39
+ "dependencies": {
40
+ "@iconify-json/fluent-color": "^1.2.21",
41
+ "@radix-ui/react-checkbox": "^1.3.3",
42
+ "@radix-ui/react-context-menu": "^2.3.7",
43
+ "@radix-ui/react-dialog": "^1.1.15",
44
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
45
+ "@radix-ui/react-popover": "^1.1.15",
46
+ "@radix-ui/react-select": "^2.2.6",
47
+ "@radix-ui/react-switch": "^1.2.6",
48
+ "@radix-ui/react-tooltip": "^1.2.8",
49
+ "clsx": "^2.1.1",
50
+ "date-fns": "^4.4.0",
51
+ "highlight.js": "^11.12.0",
52
+ "mermaid": "^11.14.0",
53
+ "react-day-picker": "^10.0.1",
54
+ "react-markdown": "^10.1.0",
55
+ "remark-gfm": "^4.0.1",
56
+ "tailwind-merge": "^3.5.0"
57
+ },
58
+ "devDependencies": {
59
+ "@types/react": "^19.1.8",
60
+ "@types/react-dom": "^19.1.6",
61
+ "react": "19.2.8",
62
+ "react-dom": "19.2.8",
63
+ "react-hook-form": "^7.87.0",
64
+ "typescript": "~5.8.3"
65
+ }
66
+ }
@@ -0,0 +1,109 @@
1
+ import { type ReactNode, useState } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ export type AlertType = "success" | "info" | "warning" | "error";
5
+
6
+ export type AlertProps = {
7
+ type?: AlertType;
8
+ /** Primary text — antd `message` / `title`. */
9
+ message?: ReactNode;
10
+ title?: ReactNode;
11
+ description?: ReactNode;
12
+ showIcon?: boolean;
13
+ closable?: boolean | { onClose?: () => void };
14
+ onClose?: () => void;
15
+ className?: string;
16
+ children?: ReactNode;
17
+ };
18
+
19
+ const TYPE_STYLES: Record<AlertType, string> = {
20
+ success: "border-success/30 bg-success/10 text-foreground",
21
+ info: "border-link/30 bg-link/10 text-foreground",
22
+ warning: "border-warn/30 bg-warn/10 text-foreground",
23
+ error: "border-destructive/30 bg-destructive/10 text-foreground",
24
+ };
25
+
26
+ const ICON_COLOR: Record<AlertType, string> = {
27
+ success: "text-success",
28
+ info: "text-link",
29
+ warning: "text-warn",
30
+ error: "text-destructive",
31
+ };
32
+
33
+ function AlertIcon({ type }: { type: AlertType }) {
34
+ const cls = cn("size-4 shrink-0 mt-0.5", ICON_COLOR[type]);
35
+ if (type === "success") {
36
+ return (
37
+ <svg className={cls} viewBox="0 0 16 16" fill="none" aria-hidden>
38
+ <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" />
39
+ <path d="M4.8 8.2L6.9 10.2L11.2 5.8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
40
+ </svg>
41
+ );
42
+ }
43
+ if (type === "error") {
44
+ return (
45
+ <svg className={cls} viewBox="0 0 16 16" fill="none" aria-hidden>
46
+ <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" />
47
+ <path d="M5.5 5.5L10.5 10.5M10.5 5.5L5.5 10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
48
+ </svg>
49
+ );
50
+ }
51
+ if (type === "warning") {
52
+ return (
53
+ <svg className={cls} viewBox="0 0 16 16" fill="none" aria-hidden>
54
+ <path d="M8 2.2L14.2 13.2H1.8L8 2.2Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
55
+ <path d="M8 6.2V9.2M8 11V11.4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
56
+ </svg>
57
+ );
58
+ }
59
+ return (
60
+ <svg className={cls} viewBox="0 0 16 16" fill="none" aria-hidden>
61
+ <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" />
62
+ <path d="M8 7.2V11.2M8 4.8V5.4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
63
+ </svg>
64
+ );
65
+ }
66
+
67
+ export function Alert({
68
+ type = "info",
69
+ message,
70
+ title,
71
+ description,
72
+ showIcon,
73
+ closable,
74
+ onClose,
75
+ className,
76
+ children,
77
+ }: AlertProps) {
78
+ const [open, setOpen] = useState(true);
79
+ if (!open) return null;
80
+
81
+ const heading = title ?? message;
82
+ const canClose = Boolean(closable);
83
+ const handleClose = () => {
84
+ setOpen(false);
85
+ if (typeof closable === "object") closable.onClose?.();
86
+ onClose?.();
87
+ };
88
+
89
+ return (
90
+ <div role="alert" className={cn("flex gap-2.5 rounded-lg border border-solid px-3 py-2.5 text-sm", TYPE_STYLES[type], className)}>
91
+ {showIcon ? <AlertIcon type={type} /> : null}
92
+ <div className="min-w-0 flex-1 flex flex-col gap-0.5">
93
+ {heading != null && heading !== "" ? <div className="font-medium leading-5">{heading}</div> : null}
94
+ {description != null && description !== "" ? <div className="leading-5 text-foreground/90">{description}</div> : null}
95
+ {children}
96
+ </div>
97
+ {canClose ? (
98
+ <button
99
+ type="button"
100
+ aria-label="Close"
101
+ className="shrink-0 -mr-1 -mt-0.5 inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-muted-foreground hover:text-foreground"
102
+ onClick={handleClose}
103
+ >
104
+ ×
105
+ </button>
106
+ ) : null}
107
+ </div>
108
+ );
109
+ }
@@ -0,0 +1,36 @@
1
+ import { type ReactNode, createContext, useContext, useLayoutEffect, useMemo } from "react";
2
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
3
+ import { applyNonlaTheme, type NonlaThemeConfig } from "../theme";
4
+
5
+ export type NonlaAppConfig = {
6
+ getPopupContainer?: () => HTMLElement;
7
+ };
8
+
9
+ const AppContext = createContext<NonlaAppConfig>({});
10
+
11
+ export function useAppConfig() {
12
+ return useContext(AppContext);
13
+ }
14
+
15
+ export type AppProps = {
16
+ children: ReactNode;
17
+ getPopupContainer?: () => HTMLElement;
18
+ /** Override `--nonla-*` knobs on `:root`. Prefer CSS in the host app when possible. */
19
+ theme?: NonlaThemeConfig;
20
+ message?: unknown;
21
+ modal?: unknown;
22
+ };
23
+
24
+ /** Root host for NonlaUI (tooltip provider + theme). Mount once near app root. */
25
+ export function App({ children, getPopupContainer, theme }: AppProps) {
26
+ const value = useMemo<NonlaAppConfig>(() => ({ getPopupContainer }), [getPopupContainer]);
27
+ useLayoutEffect(() => {
28
+ if (!theme) return;
29
+ return applyNonlaTheme(theme);
30
+ }, [theme]);
31
+ return (
32
+ <AppContext.Provider value={value}>
33
+ <TooltipPrimitive.Provider delayDuration={100}>{children}</TooltipPrimitive.Provider>
34
+ </AppContext.Provider>
35
+ );
36
+ }
@@ -0,0 +1,278 @@
1
+ import { type AnchorHTMLAttributes, type ButtonHTMLAttributes, type CSSProperties, type ForwardRefExoticComponent, type MouseEvent, type ReactNode, type RefAttributes, createContext, forwardRef, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { cn } from "../lib/cn";
3
+ import type { ControlSize } from "../lib/sizes";
4
+ import { controlHeightVar, controlRadiusVar, getSizeTokens, normalizeSize } from "../lib/sizes";
5
+
6
+ export type ButtonType = "default" | "primary" | "dashed" | "link" | "text";
7
+ export type ButtonVariant = "outlined" | "dashed" | "solid" | "filled" | "text" | "link";
8
+ export type ButtonShape = "default" | "circle" | "round" | "square";
9
+ export type ButtonHtmlType = "submit" | "button" | "reset";
10
+ export type ButtonSize = ControlSize;
11
+ export type ButtonColor = "default" | "primary" | "danger" | "blue" | "purple" | "cyan" | "green" | "magenta" | "pink" | "red" | "orange" | "yellow" | "volcano" | "geekblue" | "lime" | "gold" | "link";
12
+
13
+ type SemanticSlot = "root" | "icon" | "content";
14
+
15
+ export type ButtonProps = Omit<ButtonHTMLAttributes<HTMLElement> & AnchorHTMLAttributes<HTMLElement>, "type" | "color"> & {
16
+ type?: ButtonType;
17
+ color?: ButtonColor;
18
+ variant?: ButtonVariant;
19
+ icon?: ReactNode;
20
+ iconPosition?: "start" | "end";
21
+ iconPlacement?: "start" | "end";
22
+ shape?: ButtonShape;
23
+ size?: ButtonSize;
24
+ loading?: boolean | { delay?: number; icon?: ReactNode };
25
+ prefixCls?: string;
26
+ rootClassName?: string;
27
+ ghost?: boolean;
28
+ danger?: boolean;
29
+ block?: boolean;
30
+ href?: string;
31
+ htmlType?: ButtonHtmlType;
32
+ autoInsertSpace?: boolean;
33
+ classNames?: Partial<Record<SemanticSlot, string>>;
34
+ styles?: Partial<Record<SemanticSlot, CSSProperties>>;
35
+ };
36
+
37
+ export type ButtonGroupProps = {
38
+ size?: ButtonSize;
39
+ className?: string;
40
+ style?: CSSProperties;
41
+ children?: ReactNode;
42
+ };
43
+
44
+ const TYPE_MAP: Record<ButtonType, [ButtonColor, ButtonVariant]> = {
45
+ default: ["default", "outlined"],
46
+ primary: ["primary", "solid"],
47
+ dashed: ["default", "dashed"],
48
+ link: ["link", "link"],
49
+ text: ["default", "text"],
50
+ };
51
+
52
+ const HTML_BUTTON_TYPES = new Set(["button", "submit", "reset"]);
53
+
54
+ function isButtonType(value: unknown): value is ButtonType {
55
+ return typeof value === "string" && value in TYPE_MAP;
56
+ }
57
+
58
+ function resolveTone(type: ButtonType | undefined, color: ButtonColor | undefined, variant: ButtonVariant | undefined, danger: boolean): [ButtonColor, ButtonVariant] {
59
+ if (color && variant) return [danger ? "danger" : color, variant];
60
+ if (type || danger) {
61
+ const pair = TYPE_MAP[isButtonType(type) ? type : "default"];
62
+ return [danger ? "danger" : pair[0], pair[1]];
63
+ }
64
+ if (variant === "solid") return ["primary", "solid"];
65
+ return ["default", variant ?? "outlined"];
66
+ }
67
+
68
+ const PRESET_COLORS = new Set<ButtonColor>(["blue", "purple", "cyan", "green", "magenta", "pink", "red", "orange", "yellow", "volcano", "geekblue", "lime", "gold"]);
69
+
70
+ const TWO_CN = /^[\u4e00-\u9fa5]{2}$/;
71
+ const GroupSizeContext = createContext<ButtonSize | undefined>(undefined);
72
+
73
+ function tokenAppearance(color: "default" | "primary" | "danger" | "link", variant: ButtonVariant, ghost: boolean): string {
74
+ const g = ghost && variant !== "text" && variant !== "link";
75
+ if (color === "primary") {
76
+ if (variant === "solid" && !g) return "border-transparent bg-brand text-[var(--nonla-solid-fg)] hover:bg-[color-mix(in_oklab,var(--brand),white_14%)] active:bg-[color-mix(in_oklab,var(--brand),black_10%)]";
77
+ if (variant === "filled") return "bg-brand/15 text-brand-700 border-transparent hover:bg-brand/25";
78
+ if (variant === "text") return "bg-transparent text-brand border-transparent hover:bg-brand/10";
79
+ if (variant === "link") return "text-brand border-transparent hover:text-brand-700";
80
+ return "bg-transparent text-brand border-brand hover:bg-brand/10";
81
+ }
82
+ if (color === "danger") {
83
+ if (variant === "solid" && !g) return "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/90";
84
+ if (variant === "filled") return "bg-destructive/15 text-destructive border-transparent hover:bg-destructive/25";
85
+ if (variant === "text") return "bg-transparent text-destructive border-transparent hover:bg-destructive/10";
86
+ if (variant === "link") return "text-destructive border-transparent hover:text-destructive/80";
87
+ return "bg-transparent text-destructive border-destructive hover:bg-destructive/10";
88
+ }
89
+ if (color === "link" || variant === "link") return "text-link border-transparent hover:text-link/80";
90
+ if (variant === "filled") return "bg-muted text-foreground border-transparent hover:bg-muted/80";
91
+ if (variant === "text") return "bg-transparent text-foreground border-transparent hover:bg-foreground/6 hover:text-foreground";
92
+ if (variant === "dashed") return "bg-transparent text-foreground border-dashed border-glass-border hover:bg-glass-bar backdrop-blur-xl";
93
+ return "nonla-btn-glass text-foreground";
94
+ }
95
+
96
+ function presetAppearance(variant: ButtonVariant, ghost: boolean): string {
97
+ const g = ghost && variant !== "text" && variant !== "link";
98
+ if (variant === "solid" && !g) return "bg-(--nonla-btn) text-[var(--nonla-solid-fg)] border-transparent hover:brightness-110";
99
+ if (variant === "filled") return "bg-(--nonla-btn)/15 text-(--nonla-btn) border-transparent hover:bg-(--nonla-btn)/25";
100
+ if (variant === "link") return "text-(--nonla-btn) border-transparent hover:opacity-80";
101
+ if (variant === "dashed") return "bg-transparent text-(--nonla-btn) border-dashed border-(--nonla-btn) hover:bg-(--nonla-btn)/10";
102
+ if (variant === "text") return "bg-transparent text-(--nonla-btn) border-transparent hover:bg-(--nonla-btn)/10";
103
+ return "bg-transparent text-(--nonla-btn) border-(--nonla-btn) hover:bg-(--nonla-btn)/10";
104
+ }
105
+
106
+ function LoadingDot({ size }: { size: number }) {
107
+ return <span className="shrink-0 rounded-full border-2 border-current/25 border-t-current animate-spin" style={{ width: size, height: size }} aria-hidden />;
108
+ }
109
+
110
+ function ButtonGroup({ size, className, style, children }: ButtonGroupProps) {
111
+ return (
112
+ <GroupSizeContext.Provider value={size}>
113
+ <div role="group" className={cn("inline-flex items-stretch", className)} style={style}>
114
+ {children}
115
+ </div>
116
+ </GroupSizeContext.Provider>
117
+ );
118
+ }
119
+
120
+ const ButtonInner = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(function Button(
121
+ {
122
+ type: typeProp,
123
+ color,
124
+ variant,
125
+ icon,
126
+ iconPosition,
127
+ iconPlacement,
128
+ shape = "default",
129
+ size: sizeProp,
130
+ disabled,
131
+ loading = false,
132
+ prefixCls,
133
+ className,
134
+ rootClassName,
135
+ ghost = false,
136
+ danger = false,
137
+ block = false,
138
+ children,
139
+ classNames,
140
+ styles,
141
+ href,
142
+ htmlType = "button",
143
+ autoInsertSpace = true,
144
+ autoFocus,
145
+ onClick,
146
+ style,
147
+ ...rest
148
+ },
149
+ ref,
150
+ ) {
151
+ // Radix Trigger asChild merges native type="button"|"submit"|"reset" onto our `type` prop.
152
+ const typeIsHtml = typeof typeProp === "string" && HTML_BUTTON_TYPES.has(typeProp);
153
+ const type = typeIsHtml ? undefined : (typeProp as ButtonType | undefined);
154
+ const resolvedHtmlType = (typeIsHtml ? typeProp : htmlType) as ButtonHtmlType;
155
+
156
+ const groupSize = useContext(GroupSizeContext);
157
+ const size = normalizeSize(sizeProp ?? groupSize);
158
+ const tok = getSizeTokens(size);
159
+ const [mergedColor, mergedVariant] = resolveTone(type, color, variant, danger);
160
+ const preset = PRESET_COLORS.has(mergedColor);
161
+ const placement = iconPlacement ?? iconPosition ?? "start";
162
+ const iconOnly = children == null || children === false || children === "";
163
+
164
+ const loadingOn = typeof loading === "object" ? true : !!loading;
165
+ const loadingDelay = typeof loading === "object" ? (loading.delay ?? 0) : 0;
166
+ const [innerLoading, setInnerLoading] = useState(loadingOn && loadingDelay <= 0);
167
+
168
+ useLayoutEffect(() => {
169
+ if (!loadingOn) {
170
+ setInnerLoading(false);
171
+ return;
172
+ }
173
+ if (loadingDelay <= 0) {
174
+ setInnerLoading(true);
175
+ return;
176
+ }
177
+ const t = window.setTimeout(() => setInnerLoading(true), loadingDelay);
178
+ return () => window.clearTimeout(t);
179
+ }, [loadingOn, loadingDelay]);
180
+
181
+ const nodeRef = useRef<HTMLButtonElement | HTMLAnchorElement | null>(null);
182
+ useEffect(() => {
183
+ if (autoFocus) nodeRef.current?.focus();
184
+ }, [autoFocus]);
185
+
186
+ const setRef = (node: HTMLButtonElement | HTMLAnchorElement | null) => {
187
+ nodeRef.current = node;
188
+ if (typeof ref === "function") ref(node);
189
+ else if (ref) (ref as { current: HTMLButtonElement | HTMLAnchorElement | null }).current = node;
190
+ };
191
+
192
+ const content = useMemo(() => {
193
+ if (typeof children === "string" && autoInsertSpace && TWO_CN.test(children)) return `${children[0]} ${children[1]}`;
194
+ return children;
195
+ }, [children, autoInsertSpace]);
196
+
197
+ const iconNode = innerLoading ? typeof loading === "object" && loading.icon ? loading.icon : <LoadingDot size={tok.icon} /> : icon;
198
+ const showIcon = Boolean(iconNode);
199
+ const radius = shape === "circle" || shape === "round" ? 9999 : shape === "square" ? 4 : controlRadiusVar(size);
200
+ const appearance = preset ? presetAppearance(mergedVariant, ghost) : tokenAppearance(mergedColor as "default" | "primary" | "danger" | "link", mergedVariant, ghost);
201
+ const bordered = mergedVariant !== "text" && mergedVariant !== "link";
202
+
203
+ const padText = tok.paddingInline;
204
+ const paddingLeft = iconOnly ? 0 : showIcon && placement === "start" ? tok.paddingInlineIconStart : padText;
205
+ const paddingRight = iconOnly ? 0 : showIcon && placement === "end" ? tok.paddingInlineIconEnd : padText;
206
+
207
+ const rootClass = cn(
208
+ "inline-flex items-center justify-center gap-2 font-normal whitespace-nowrap select-none cursor-pointer border border-solid transition-colors duration-150",
209
+ "focus-visible:outline-none",
210
+ "disabled:cursor-not-allowed disabled:opacity-45",
211
+ bordered ? "border" : "border-transparent",
212
+ appearance,
213
+ block && "flex w-full",
214
+ innerLoading && "pointer-events-none opacity-80",
215
+ placement === "end" && "flex-row-reverse",
216
+ prefixCls,
217
+ className,
218
+ rootClassName,
219
+ classNames?.root,
220
+ );
221
+
222
+ const boxH = controlHeightVar(size);
223
+ const mergedStyle: CSSProperties = {
224
+ height: boxH,
225
+ width: iconOnly ? boxH : undefined,
226
+ fontSize: tok.fontSize,
227
+ lineHeight: `${tok.lineHeight}px`,
228
+ paddingLeft,
229
+ paddingRight,
230
+ borderRadius: radius,
231
+ ...(preset ? ({ "--nonla-btn": `var(--nonla-${mergedColor})` } as CSSProperties) : null),
232
+ ...styles?.root,
233
+ ...style,
234
+ };
235
+
236
+ const handleClick = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
237
+ if (innerLoading || disabled) {
238
+ e.preventDefault();
239
+ return;
240
+ }
241
+ onClick?.(e);
242
+ };
243
+
244
+ const inner = (
245
+ <>
246
+ {showIcon ? (
247
+ <span className={cn("relative z-10 inline-flex shrink-0 items-center justify-center [&_svg]:size-full", classNames?.icon)} style={{ width: tok.icon, height: tok.icon, ...styles?.icon }}>
248
+ {iconNode}
249
+ </span>
250
+ ) : null}
251
+ {content != null && content !== false && content !== "" ? (
252
+ <span className={cn("relative z-10 inline-flex min-w-0 items-center", classNames?.content)} style={styles?.content}>
253
+ {content}
254
+ </span>
255
+ ) : null}
256
+ </>
257
+ );
258
+
259
+ if (href !== undefined) {
260
+ return (
261
+ <a {...rest} ref={setRef} href={disabled ? undefined : href} className={rootClass} style={mergedStyle} onClick={handleClick} tabIndex={disabled ? -1 : rest.tabIndex} aria-disabled={disabled || undefined} aria-busy={innerLoading || undefined}>
262
+ {inner}
263
+ </a>
264
+ );
265
+ }
266
+
267
+ return (
268
+ <button {...rest} ref={setRef} type={resolvedHtmlType} className={rootClass} style={mergedStyle} onClick={handleClick} disabled={disabled} aria-busy={innerLoading || undefined}>
269
+ {inner}
270
+ </button>
271
+ );
272
+ });
273
+
274
+ type ButtonComponent = ForwardRefExoticComponent<ButtonProps & RefAttributes<HTMLButtonElement | HTMLAnchorElement>> & {
275
+ Group: typeof ButtonGroup;
276
+ };
277
+
278
+ export const Button = Object.assign(ButtonInner, { Group: ButtonGroup }) as ButtonComponent;