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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/package.json +66 -0
- package/src/alert/Alert.tsx +109 -0
- package/src/app/App.tsx +36 -0
- package/src/button/Button.tsx +278 -0
- package/src/button/ButtonCopy.tsx +60 -0
- package/src/calendar/Calendar.tsx +133 -0
- package/src/chat/AgentPanel.tsx +203 -0
- package/src/chat/common/bgTasks.ts +41 -0
- package/src/chat/common/sse.ts +156 -0
- package/src/chat/common/types.ts +43 -0
- package/src/chat/common/useAgentStream.ts +351 -0
- package/src/chat/common/utils.ts +73 -0
- package/src/chat/index.ts +56 -0
- package/src/chat/message-ui/ChatAgentMessage.tsx +29 -0
- package/src/chat/message-ui/ChatError.tsx +24 -0
- package/src/chat/message-ui/ChatInput.tsx +162 -0
- package/src/chat/message-ui/ChatMarkdown.tsx +128 -0
- package/src/chat/message-ui/ChatMarkdownTable.tsx +45 -0
- package/src/chat/message-ui/ChatSpinner.tsx +5 -0
- package/src/chat/message-ui/ChatThinking.tsx +61 -0
- package/src/chat/message-ui/ChatUserMessage.tsx +72 -0
- package/src/chat/message-ui/ChatWelcome.tsx +70 -0
- package/src/chat/message-ui/MermaidBlock.tsx +266 -0
- package/src/chat/message-ui/mermaidFence.ts +55 -0
- package/src/chat/message-ui/sanitizeMermaid.ts +135 -0
- package/src/chat/tool-ui/BackgroundTaskToolUI.tsx +36 -0
- package/src/chat/tool-ui/BackgroundTasksBar.tsx +152 -0
- package/src/chat/tool-ui/CallAgentToolUI.tsx +168 -0
- package/src/chat/tool-ui/ChatToolCall.tsx +77 -0
- package/src/chat/tool-ui/GetCurrentTimeToolUI.tsx +49 -0
- package/src/chat/tool-ui/ReadSkillToolUI.tsx +80 -0
- package/src/chat/tool-ui/RunJsToolUI.tsx +106 -0
- package/src/chat/tool-ui/ToolUiBadge.tsx +10 -0
- package/src/chat/tool-ui/ToolUiTrailing.tsx +24 -0
- package/src/chat/tool-ui/WebFetchToolUI.tsx +53 -0
- package/src/chat/tool-ui/registry.ts +29 -0
- package/src/chat/tool-ui/types.ts +22 -0
- package/src/checkbox/Checkbox.tsx +50 -0
- package/src/codeblock/CodeBlock.tsx +76 -0
- package/src/codeblock/highlight.ts +159 -0
- package/src/colorpicker/ColorPicker.tsx +45 -0
- package/src/datepicker/DatePicker.tsx +481 -0
- package/src/desktop/DesktopHeader.tsx +92 -0
- package/src/desktop/DesktopIcon.tsx +34 -0
- package/src/desktop/DesktopStage.tsx +6 -0
- package/src/desktop/DesktopWindow.tsx +504 -0
- package/src/desktop/MeadowDesktop.tsx +15 -0
- package/src/desktop/MeadowShell.tsx +12 -0
- package/src/desktop/MeadowWallpaper.tsx +5 -0
- package/src/desktop/assets/desktop-meadow.jpg +0 -0
- package/src/desktop/assets.d.ts +9 -0
- package/src/desktop/surface.ts +1 -0
- package/src/drawer/Drawer.tsx +125 -0
- package/src/dropdown/ContextMenu.tsx +97 -0
- package/src/dropdown/Dropdown.tsx +139 -0
- package/src/dropdown/menuClasses.ts +9 -0
- package/src/empty/Empty.tsx +47 -0
- package/src/form/Form.tsx +95 -0
- package/src/form/FormItem.tsx +145 -0
- package/src/form/common/context.ts +13 -0
- package/src/form/common/enum.ts +20 -0
- package/src/form/common/rules.ts +68 -0
- package/src/form/common/types.ts +169 -0
- package/src/form/index.ts +35 -0
- package/src/form/partials/FormItemHelps.tsx +31 -0
- package/src/form/variants/FieldCheckbox.tsx +20 -0
- package/src/form/variants/FieldColor.tsx +63 -0
- package/src/form/variants/FieldDateTime.tsx +25 -0
- package/src/form/variants/FieldHidden.tsx +9 -0
- package/src/form/variants/FieldInput.tsx +24 -0
- package/src/form/variants/FieldNumber.tsx +28 -0
- package/src/form/variants/FieldObject.tsx +29 -0
- package/src/form/variants/FieldRadio.tsx +40 -0
- package/src/form/variants/FieldRepeater.tsx +62 -0
- package/src/form/variants/FieldRepeaterItem.tsx +64 -0
- package/src/form/variants/FieldSelect.tsx +27 -0
- package/src/form/variants/FieldSelectMultiple.tsx +32 -0
- package/src/form/variants/FieldSelectRemote.tsx +66 -0
- package/src/form/variants/FieldSwitch.tsx +17 -0
- package/src/form/variants/FieldTextarea.tsx +25 -0
- package/src/form/variants/FieldTime.tsx +25 -0
- package/src/form-layout/FormLayout.tsx +74 -0
- package/src/icon/FluentIcon.tsx +28 -0
- package/src/icon/fluent.ts +75 -0
- package/src/index.ts +214 -0
- package/src/input/Input.tsx +371 -0
- package/src/input/SearchInput.tsx +73 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/placement.ts +44 -0
- package/src/lib/sizes.ts +117 -0
- package/src/lib/surface.ts +8 -0
- package/src/menu/Menu.tsx +98 -0
- package/src/message/message.tsx +214 -0
- package/src/modal/Modal.tsx +277 -0
- package/src/pagination/Pagination.tsx +103 -0
- package/src/popconfirm/Popconfirm.tsx +134 -0
- package/src/popover/Popover.tsx +116 -0
- package/src/scroll/OverlayScroll.tsx +141 -0
- package/src/segmented/Segmented.tsx +67 -0
- package/src/select/Select.tsx +319 -0
- package/src/skeleton/Skeleton.tsx +47 -0
- package/src/spin/Spin.tsx +160 -0
- package/src/styles.css +897 -0
- package/src/switch/Switch.tsx +58 -0
- package/src/table/Table.tsx +387 -0
- package/src/tag/Tag.tsx +109 -0
- package/src/theme.ts +131 -0
- package/src/timepicker/TimePicker.tsx +751 -0
- package/src/tooltip/Tooltip.tsx +59 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { type ButtonHTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { FluentIcon } from "../icon/FluentIcon";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import type { PopperPlacement } from "../lib/placement";
|
|
5
|
+
import { Popover } from "../popover/Popover";
|
|
6
|
+
|
|
7
|
+
export type MenuRootProps = {
|
|
8
|
+
open?: boolean;
|
|
9
|
+
onOpenChange?: (open: boolean) => void;
|
|
10
|
+
trigger: ReactNode;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
contentClassName?: string;
|
|
13
|
+
placement?: PopperPlacement;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function Menu({ open, onOpenChange, trigger, children, contentClassName, placement = "bottomLeft" }: MenuRootProps) {
|
|
17
|
+
return (
|
|
18
|
+
<Popover
|
|
19
|
+
open={open}
|
|
20
|
+
onOpenChange={onOpenChange}
|
|
21
|
+
trigger="click"
|
|
22
|
+
placement={placement}
|
|
23
|
+
contentClassName={cn("w-[220px] p-1", contentClassName)}
|
|
24
|
+
content={<div className="flex flex-col gap-px">{children}</div>}
|
|
25
|
+
>
|
|
26
|
+
{trigger}
|
|
27
|
+
</Popover>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type MenuTriggerProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
32
|
+
active?: boolean;
|
|
33
|
+
open?: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function MenuTrigger({ active, open, className, children, ...rest }: MenuTriggerProps) {
|
|
37
|
+
return (
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
aria-haspopup="menu"
|
|
41
|
+
aria-expanded={open}
|
|
42
|
+
aria-current={active ? "true" : undefined}
|
|
43
|
+
className={cn(
|
|
44
|
+
"inline-flex h-7 max-w-44 items-center gap-0.5 rounded-md border-0 bg-transparent px-2.5 text-sm font-medium leading-5 cursor-pointer",
|
|
45
|
+
active || open ? "bg-ink-active text-foreground" : "text-foreground/85 hover:bg-ink-hover hover:text-foreground",
|
|
46
|
+
className,
|
|
47
|
+
)}
|
|
48
|
+
{...rest}
|
|
49
|
+
>
|
|
50
|
+
<span className="min-w-0 truncate leading-5">{children}</span>
|
|
51
|
+
<FluentIcon name="arrow-square-down-24" size={12} className={cn("shrink-0 transition-transform duration-150", open && "rotate-180")} />
|
|
52
|
+
</button>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type MenuItemProps = {
|
|
57
|
+
icon?: ReactNode;
|
|
58
|
+
label: ReactNode;
|
|
59
|
+
extra?: ReactNode;
|
|
60
|
+
selected?: boolean;
|
|
61
|
+
action?: ReactNode;
|
|
62
|
+
onClick?: () => void;
|
|
63
|
+
className?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function MenuItem({ icon, label, extra, selected, action, onClick, className }: MenuItemProps) {
|
|
67
|
+
return (
|
|
68
|
+
<div className={cn("group relative flex min-h-8 items-center rounded-md", selected ? "bg-ink-active" : "hover:bg-foreground/5", className)}>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
aria-current={selected ? "true" : undefined}
|
|
72
|
+
onClick={onClick}
|
|
73
|
+
className={cn(
|
|
74
|
+
"flex min-h-8 w-full items-center gap-2 rounded-md border-0 bg-transparent px-2.5 text-left text-sm cursor-pointer",
|
|
75
|
+
selected ? "font-medium text-foreground" : "font-normal text-foreground",
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{icon ? <span className="inline-flex size-4 shrink-0 items-center justify-center [&_img]:size-4">{icon}</span> : null}
|
|
79
|
+
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
80
|
+
{extra != null ? <span className={cn("shrink-0 text-xs tabular-nums text-muted-foreground", action && "group-hover:invisible")}>{extra}</span> : null}
|
|
81
|
+
</button>
|
|
82
|
+
{action ? <div className="absolute right-1 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100">{action}</div> : null}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function MenuAction({ className, ...rest }: ButtonHTMLAttributes<HTMLButtonElement>) {
|
|
88
|
+
return <button type="button" className={cn("flex size-6 items-center justify-center rounded-md border-0 bg-transparent p-0 cursor-pointer hover:bg-ink-active", className)} {...rest} />;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function MenuDivider({ className }: { className?: string }) {
|
|
92
|
+
return <div className={cn("mx-2.5 my-1 h-px bg-ink-line", className)} />;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
Menu.Trigger = MenuTrigger;
|
|
96
|
+
Menu.Item = MenuItem;
|
|
97
|
+
Menu.Action = MenuAction;
|
|
98
|
+
Menu.Divider = MenuDivider;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
3
|
+
|
|
4
|
+
export type MessageType = "success" | "error" | "info" | "warning" | "loading";
|
|
5
|
+
|
|
6
|
+
export type MessageConfig = {
|
|
7
|
+
content: ReactNode;
|
|
8
|
+
duration?: number;
|
|
9
|
+
type?: MessageType;
|
|
10
|
+
key?: string | number;
|
|
11
|
+
icon?: ReactNode;
|
|
12
|
+
onClose?: () => void;
|
|
13
|
+
onClick?: () => void;
|
|
14
|
+
className?: string;
|
|
15
|
+
style?: CSSProperties;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type Active = {
|
|
19
|
+
id: string;
|
|
20
|
+
type: MessageType;
|
|
21
|
+
content: ReactNode;
|
|
22
|
+
icon?: ReactNode;
|
|
23
|
+
onClose?: () => void;
|
|
24
|
+
onClick?: () => void;
|
|
25
|
+
className?: string;
|
|
26
|
+
style?: CSSProperties;
|
|
27
|
+
leaving?: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type TypeOpen = {
|
|
31
|
+
(content: ReactNode, duration?: number, onClose?: () => void): () => void;
|
|
32
|
+
(config: MessageConfig): () => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let hostEl: HTMLDivElement | null = null;
|
|
36
|
+
let root: Root | null = null;
|
|
37
|
+
let items: Active[] = [];
|
|
38
|
+
const timers = new Map<string, number>();
|
|
39
|
+
|
|
40
|
+
function IconSuccess() {
|
|
41
|
+
return (
|
|
42
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
|
|
43
|
+
<circle cx="8" cy="8" r="8" fill="var(--success)" />
|
|
44
|
+
<path d="M4.6 8.15L6.85 10.3L11.4 5.7" stroke="var(--nonla-bg)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
|
45
|
+
</svg>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function IconError() {
|
|
50
|
+
return (
|
|
51
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
|
|
52
|
+
<circle cx="8" cy="8" r="8" fill="var(--destructive)" />
|
|
53
|
+
<path d="M5.4 5.4L10.6 10.6M10.6 5.4L5.4 10.6" stroke="var(--destructive-foreground)" strokeWidth="1.6" strokeLinecap="round" />
|
|
54
|
+
</svg>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function IconInfo() {
|
|
59
|
+
return (
|
|
60
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
|
|
61
|
+
<circle cx="8" cy="8" r="8" fill="var(--link)" />
|
|
62
|
+
<path d="M8 7.1V11.2M8 4.8V5.5" stroke="var(--nonla-solid-fg)" strokeWidth="1.7" strokeLinecap="round" />
|
|
63
|
+
</svg>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function IconWarning() {
|
|
68
|
+
return (
|
|
69
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
|
|
70
|
+
<circle cx="8" cy="8" r="8" fill="var(--warn)" />
|
|
71
|
+
<path d="M8 4.6V8.8M8 10.8V11.4" stroke="var(--nonla-bg)" strokeWidth="1.7" strokeLinecap="round" />
|
|
72
|
+
</svg>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function IconLoading() {
|
|
77
|
+
return (
|
|
78
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden className="nonla-message-spin">
|
|
79
|
+
<circle cx="8" cy="8" r="6.25" stroke="color-mix(in oklab, var(--link) 30%, transparent)" strokeWidth="2" />
|
|
80
|
+
<path d="M14.25 8A6.25 6.25 0 0 0 8 1.75" stroke="var(--link)" strokeWidth="2" strokeLinecap="round" />
|
|
81
|
+
</svg>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function defaultIcon(type: MessageType) {
|
|
86
|
+
switch (type) {
|
|
87
|
+
case "success":
|
|
88
|
+
return <IconSuccess />;
|
|
89
|
+
case "error":
|
|
90
|
+
return <IconError />;
|
|
91
|
+
case "warning":
|
|
92
|
+
return <IconWarning />;
|
|
93
|
+
case "loading":
|
|
94
|
+
return <IconLoading />;
|
|
95
|
+
default:
|
|
96
|
+
return <IconInfo />;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function MessageList({ list }: { list: Active[] }) {
|
|
101
|
+
return (
|
|
102
|
+
<>
|
|
103
|
+
{list.map((item) => (
|
|
104
|
+
<div
|
|
105
|
+
key={item.id}
|
|
106
|
+
className={["nonla-message-item", "nonla-glass", item.leaving ? "is-leaving" : "", item.className].filter(Boolean).join(" ")}
|
|
107
|
+
data-type={item.type}
|
|
108
|
+
role="status"
|
|
109
|
+
style={item.style}
|
|
110
|
+
onClick={item.onClick}
|
|
111
|
+
>
|
|
112
|
+
<span className="nonla-message-icon">{item.icon ?? defaultIcon(item.type)}</span>
|
|
113
|
+
<span className="nonla-message-content">{item.content}</span>
|
|
114
|
+
</div>
|
|
115
|
+
))}
|
|
116
|
+
</>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function ensureHost() {
|
|
121
|
+
if (hostEl && root) return;
|
|
122
|
+
hostEl = document.createElement("div");
|
|
123
|
+
hostEl.className = "nonla-message-host";
|
|
124
|
+
document.body.appendChild(hostEl);
|
|
125
|
+
root = createRoot(hostEl);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function render() {
|
|
129
|
+
ensureHost();
|
|
130
|
+
root?.render(<MessageList list={items} />);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function beginLeave(id: string) {
|
|
134
|
+
const found = items.find((i) => i.id === id);
|
|
135
|
+
if (!found || found.leaving) return;
|
|
136
|
+
items = items.map((i) => (i.id === id ? { ...i, leaving: true } : i));
|
|
137
|
+
const t = timers.get(id);
|
|
138
|
+
if (t) {
|
|
139
|
+
window.clearTimeout(t);
|
|
140
|
+
timers.delete(id);
|
|
141
|
+
}
|
|
142
|
+
render();
|
|
143
|
+
window.setTimeout(() => {
|
|
144
|
+
items = items.filter((i) => i.id !== id);
|
|
145
|
+
found.onClose?.();
|
|
146
|
+
render();
|
|
147
|
+
}, 160);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function remove(id: string) {
|
|
151
|
+
beginLeave(id);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isConfig(value: unknown): value is MessageConfig {
|
|
155
|
+
return typeof value === "object" && value !== null && "content" in value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function openTyped(type: MessageType, contentOrConfig: ReactNode | MessageConfig, duration?: number, onClose?: () => void) {
|
|
159
|
+
const cfg: MessageConfig = isConfig(contentOrConfig)
|
|
160
|
+
? contentOrConfig
|
|
161
|
+
: { content: contentOrConfig, duration, onClose };
|
|
162
|
+
|
|
163
|
+
const id = String(cfg.key ?? `msg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`);
|
|
164
|
+
const resolvedType = cfg.type ?? type;
|
|
165
|
+
const resolvedDuration = cfg.duration ?? (resolvedType === "loading" ? 0 : 3);
|
|
166
|
+
|
|
167
|
+
const existingTimer = timers.get(id);
|
|
168
|
+
if (existingTimer) {
|
|
169
|
+
window.clearTimeout(existingTimer);
|
|
170
|
+
timers.delete(id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
items = [
|
|
174
|
+
...items.filter((i) => i.id !== id),
|
|
175
|
+
{
|
|
176
|
+
id,
|
|
177
|
+
type: resolvedType,
|
|
178
|
+
content: cfg.content,
|
|
179
|
+
icon: cfg.icon,
|
|
180
|
+
onClose: cfg.onClose,
|
|
181
|
+
onClick: cfg.onClick,
|
|
182
|
+
className: cfg.className,
|
|
183
|
+
style: cfg.style,
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
render();
|
|
187
|
+
|
|
188
|
+
if (resolvedDuration > 0) {
|
|
189
|
+
const timerId = window.setTimeout(() => beginLeave(id), resolvedDuration * 1000);
|
|
190
|
+
timers.set(id, timerId);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return () => remove(id);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function makeTypeOpen(type: MessageType): TypeOpen {
|
|
197
|
+
return ((contentOrConfig: ReactNode | MessageConfig, duration?: number, onClose?: () => void) =>
|
|
198
|
+
openTyped(type, contentOrConfig, duration, onClose)) as TypeOpen;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const message = {
|
|
202
|
+
open: (config: MessageConfig) => openTyped(config.type ?? "info", config),
|
|
203
|
+
success: makeTypeOpen("success"),
|
|
204
|
+
error: makeTypeOpen("error"),
|
|
205
|
+
info: makeTypeOpen("info"),
|
|
206
|
+
warning: makeTypeOpen("warning"),
|
|
207
|
+
loading: makeTypeOpen("loading"),
|
|
208
|
+
destroy: (key?: string | number) => {
|
|
209
|
+
if (key != null) remove(String(key));
|
|
210
|
+
else {
|
|
211
|
+
for (const id of items.map((i) => i.id)) remove(id);
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import * as Dialog from "@radix-ui/react-dialog";
|
|
2
|
+
import {
|
|
3
|
+
type AnimationEvent,
|
|
4
|
+
type CSSProperties,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
11
|
+
import { Button } from "../button/Button";
|
|
12
|
+
import { cn } from "../lib/cn";
|
|
13
|
+
import { glassSurfaceClass } from "../lib/surface";
|
|
14
|
+
|
|
15
|
+
/** Matches `--nonla-dur-fast` exit animation; fallback if animationend is skipped. */
|
|
16
|
+
const EXIT_MS = 200;
|
|
17
|
+
|
|
18
|
+
export type ModalProps = {
|
|
19
|
+
open?: boolean;
|
|
20
|
+
visible?: boolean;
|
|
21
|
+
title?: ReactNode;
|
|
22
|
+
children?: ReactNode;
|
|
23
|
+
footer?: ReactNode | null;
|
|
24
|
+
onOk?: () => void | Promise<void>;
|
|
25
|
+
onCancel?: () => void;
|
|
26
|
+
onClose?: () => void;
|
|
27
|
+
afterClose?: () => void;
|
|
28
|
+
okText?: ReactNode;
|
|
29
|
+
cancelText?: ReactNode;
|
|
30
|
+
okButtonProps?: Record<string, unknown>;
|
|
31
|
+
cancelButtonProps?: Record<string, unknown>;
|
|
32
|
+
confirmLoading?: boolean;
|
|
33
|
+
width?: number | string;
|
|
34
|
+
centered?: boolean;
|
|
35
|
+
destroyOnClose?: boolean;
|
|
36
|
+
destroyOnHidden?: boolean;
|
|
37
|
+
closable?: boolean;
|
|
38
|
+
maskClosable?: boolean;
|
|
39
|
+
className?: string;
|
|
40
|
+
/** Root positioning (antd `style={{ top }}`). */
|
|
41
|
+
style?: CSSProperties;
|
|
42
|
+
styles?: { body?: CSSProperties; content?: CSSProperties; header?: CSSProperties; footer?: CSSProperties; container?: CSSProperties };
|
|
43
|
+
onOpenChange?: (open: boolean) => void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type ModalConfirmProps = {
|
|
47
|
+
title?: ReactNode;
|
|
48
|
+
content?: ReactNode;
|
|
49
|
+
okText?: ReactNode;
|
|
50
|
+
cancelText?: ReactNode;
|
|
51
|
+
okType?: "primary" | "danger" | "default";
|
|
52
|
+
okButtonProps?: { danger?: boolean; color?: string; type?: string };
|
|
53
|
+
onOk?: () => void | Promise<void>;
|
|
54
|
+
onCancel?: () => void;
|
|
55
|
+
centered?: boolean;
|
|
56
|
+
width?: number | string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type ConfirmHandle = { destroy: () => void; update: (p: Partial<ModalConfirmProps>) => void };
|
|
60
|
+
|
|
61
|
+
function ModalView({
|
|
62
|
+
open,
|
|
63
|
+
visible,
|
|
64
|
+
title,
|
|
65
|
+
children,
|
|
66
|
+
footer,
|
|
67
|
+
onOk,
|
|
68
|
+
onCancel,
|
|
69
|
+
onClose,
|
|
70
|
+
afterClose,
|
|
71
|
+
okText = "OK",
|
|
72
|
+
cancelText = "Cancel",
|
|
73
|
+
okButtonProps,
|
|
74
|
+
cancelButtonProps,
|
|
75
|
+
confirmLoading,
|
|
76
|
+
width = 480,
|
|
77
|
+
centered = false,
|
|
78
|
+
destroyOnClose,
|
|
79
|
+
destroyOnHidden,
|
|
80
|
+
closable = true,
|
|
81
|
+
maskClosable = true,
|
|
82
|
+
className,
|
|
83
|
+
style,
|
|
84
|
+
styles,
|
|
85
|
+
onOpenChange,
|
|
86
|
+
}: ModalProps) {
|
|
87
|
+
const isOpen = open ?? visible ?? false;
|
|
88
|
+
const [loading, setLoading] = useState(false);
|
|
89
|
+
const shouldDestroy = Boolean(destroyOnHidden ?? destroyOnClose);
|
|
90
|
+
// Keep body mounted through the exit animation; clear only after it finishes.
|
|
91
|
+
const [present, setPresent] = useState(isOpen);
|
|
92
|
+
const closedRef = useRef(true);
|
|
93
|
+
const wasOpenRef = useRef(isOpen);
|
|
94
|
+
const afterCloseRef = useRef(afterClose);
|
|
95
|
+
afterCloseRef.current = afterClose;
|
|
96
|
+
|
|
97
|
+
const finishExit = () => {
|
|
98
|
+
if (closedRef.current) return;
|
|
99
|
+
closedRef.current = true;
|
|
100
|
+
if (shouldDestroy) setPresent(false);
|
|
101
|
+
afterCloseRef.current?.();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (isOpen) {
|
|
106
|
+
closedRef.current = false;
|
|
107
|
+
wasOpenRef.current = true;
|
|
108
|
+
setPresent(true);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// Only schedule exit cleanup after an open→close transition (not initial mount).
|
|
112
|
+
if (!wasOpenRef.current) return;
|
|
113
|
+
wasOpenRef.current = false;
|
|
114
|
+
const t = window.setTimeout(finishExit, EXIT_MS);
|
|
115
|
+
return () => window.clearTimeout(t);
|
|
116
|
+
}, [isOpen, shouldDestroy]);
|
|
117
|
+
|
|
118
|
+
const body = !shouldDestroy || present ? children : null;
|
|
119
|
+
|
|
120
|
+
const close = () => {
|
|
121
|
+
onOpenChange?.(false);
|
|
122
|
+
onCancel?.();
|
|
123
|
+
onClose?.();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const handleOk = async () => {
|
|
127
|
+
try {
|
|
128
|
+
setLoading(true);
|
|
129
|
+
await onOk?.();
|
|
130
|
+
onOpenChange?.(false);
|
|
131
|
+
} finally {
|
|
132
|
+
setLoading(false);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const busy = confirmLoading ?? loading;
|
|
137
|
+
|
|
138
|
+
const defaultFooter =
|
|
139
|
+
footer === null ? null : footer !== undefined ? (
|
|
140
|
+
footer
|
|
141
|
+
) : (
|
|
142
|
+
<>
|
|
143
|
+
<Button type="default" onClick={close} {...(cancelButtonProps as object)}>
|
|
144
|
+
{cancelText}
|
|
145
|
+
</Button>
|
|
146
|
+
<Button type="primary" loading={busy} onClick={handleOk} {...(okButtonProps as object)}>
|
|
147
|
+
{okText}
|
|
148
|
+
</Button>
|
|
149
|
+
</>
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const onContentAnimationEnd = (e: AnimationEvent<HTMLDivElement>) => {
|
|
153
|
+
if (e.target !== e.currentTarget) return;
|
|
154
|
+
if (!isOpen) finishExit();
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<Dialog.Root
|
|
159
|
+
open={isOpen}
|
|
160
|
+
onOpenChange={(next) => {
|
|
161
|
+
onOpenChange?.(next);
|
|
162
|
+
if (!next) {
|
|
163
|
+
onCancel?.();
|
|
164
|
+
onClose?.();
|
|
165
|
+
}
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
{isOpen || present ? (
|
|
169
|
+
<Dialog.Portal>
|
|
170
|
+
<Dialog.Overlay className="nonla-modal-overlay" />
|
|
171
|
+
<Dialog.Content
|
|
172
|
+
className={cn("nonla-modal-content", glassSurfaceClass, className)}
|
|
173
|
+
data-centered={centered ? "true" : undefined}
|
|
174
|
+
style={{ width, ...style, ...styles?.container, ...styles?.content }}
|
|
175
|
+
onAnimationEnd={onContentAnimationEnd}
|
|
176
|
+
onPointerDownOutside={(e) => {
|
|
177
|
+
if (!maskClosable) e.preventDefault();
|
|
178
|
+
}}
|
|
179
|
+
onInteractOutside={(e) => {
|
|
180
|
+
if (!maskClosable) e.preventDefault();
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
{(title || closable) && (
|
|
184
|
+
<div className="nonla-modal-header" style={styles?.header}>
|
|
185
|
+
<Dialog.Title className="nonla-modal-title">{title}</Dialog.Title>
|
|
186
|
+
{closable ? (
|
|
187
|
+
<Dialog.Close className="nonla-modal-close" aria-label="Close">
|
|
188
|
+
×
|
|
189
|
+
</Dialog.Close>
|
|
190
|
+
) : null}
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
<div className="nonla-modal-body" style={styles?.body}>
|
|
194
|
+
{body}
|
|
195
|
+
</div>
|
|
196
|
+
{defaultFooter != null ? (
|
|
197
|
+
<div className="nonla-modal-footer" style={styles?.footer}>
|
|
198
|
+
{typeof defaultFooter === "function" ? null : defaultFooter}
|
|
199
|
+
</div>
|
|
200
|
+
) : null}
|
|
201
|
+
</Dialog.Content>
|
|
202
|
+
</Dialog.Portal>
|
|
203
|
+
) : null}
|
|
204
|
+
</Dialog.Root>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function ConfirmHost({ initial, onDone }: { initial: ModalConfirmProps; onDone: () => void }) {
|
|
209
|
+
const [open, setOpen] = useState(true);
|
|
210
|
+
const [props, setProps] = useState(initial);
|
|
211
|
+
const [loading, setLoading] = useState(false);
|
|
212
|
+
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
(ConfirmHost as unknown as { _update?: (p: Partial<ModalConfirmProps>) => void })._update = (p) => setProps((prev) => ({ ...prev, ...p }));
|
|
215
|
+
}, []);
|
|
216
|
+
|
|
217
|
+
const close = () => {
|
|
218
|
+
setOpen(false);
|
|
219
|
+
props.onCancel?.();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const ok = async () => {
|
|
223
|
+
try {
|
|
224
|
+
setLoading(true);
|
|
225
|
+
await props.onOk?.();
|
|
226
|
+
setOpen(false);
|
|
227
|
+
} finally {
|
|
228
|
+
setLoading(false);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const danger = props.okType === "danger" || props.okButtonProps?.danger;
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<ModalView
|
|
236
|
+
open={open}
|
|
237
|
+
title={props.title}
|
|
238
|
+
width={props.width}
|
|
239
|
+
centered={props.centered}
|
|
240
|
+
confirmLoading={loading}
|
|
241
|
+
okText={props.okText ?? "OK"}
|
|
242
|
+
cancelText={props.cancelText ?? "Cancel"}
|
|
243
|
+
okButtonProps={{ danger }}
|
|
244
|
+
onOk={ok}
|
|
245
|
+
onCancel={close}
|
|
246
|
+
afterClose={onDone}
|
|
247
|
+
onOpenChange={(v) => {
|
|
248
|
+
if (!v) close();
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
{props.content}
|
|
252
|
+
</ModalView>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function mountConfirm(props: ModalConfirmProps): ConfirmHandle {
|
|
257
|
+
const el = document.createElement("div");
|
|
258
|
+
document.body.appendChild(el);
|
|
259
|
+
const root: Root = createRoot(el);
|
|
260
|
+
const destroy = () => {
|
|
261
|
+
root.unmount();
|
|
262
|
+
el.remove();
|
|
263
|
+
};
|
|
264
|
+
root.render(<ConfirmHost initial={props} onDone={destroy} />);
|
|
265
|
+
return {
|
|
266
|
+
destroy,
|
|
267
|
+
update: (p) => (ConfirmHost as unknown as { _update?: (x: Partial<ModalConfirmProps>) => void })._update?.(p),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const Modal = Object.assign(ModalView, {
|
|
272
|
+
confirm: (props: ModalConfirmProps) => mountConfirm(props),
|
|
273
|
+
info: (props: ModalConfirmProps) => mountConfirm(props),
|
|
274
|
+
warning: (props: ModalConfirmProps) => mountConfirm(props),
|
|
275
|
+
error: (props: ModalConfirmProps) => mountConfirm({ ...props, okType: "danger" }),
|
|
276
|
+
success: (props: ModalConfirmProps) => mountConfirm(props),
|
|
277
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import { type ControlSize, controlHeightVar, normalizeSize } from "../lib/sizes";
|
|
4
|
+
|
|
5
|
+
export type PaginationItemType = "page" | "prev" | "next" | "jump-prev" | "jump-next";
|
|
6
|
+
|
|
7
|
+
export type PaginationProps = {
|
|
8
|
+
current?: number;
|
|
9
|
+
pageSize?: number;
|
|
10
|
+
total?: number;
|
|
11
|
+
onChange?: (page: number, pageSize: number) => void;
|
|
12
|
+
showSizeChanger?: boolean;
|
|
13
|
+
className?: string;
|
|
14
|
+
simple?: boolean;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
size?: ControlSize;
|
|
17
|
+
itemRender?: (page: number, type: PaginationItemType, originalElement: ReactNode) => ReactNode;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function pageList(current: number, pages: number): (number | "ellipsis")[] {
|
|
21
|
+
if (pages <= 7) return Array.from({ length: pages }, (_, i) => i + 1);
|
|
22
|
+
const set = new Set<number>([1, pages, current, current - 1, current + 1]);
|
|
23
|
+
if (current <= 3) [2, 3, 4].forEach((n) => set.add(n));
|
|
24
|
+
if (current >= pages - 2) [pages - 1, pages - 2, pages - 3].forEach((n) => set.add(n));
|
|
25
|
+
const nums = [...set].filter((n) => n >= 1 && n <= pages).sort((a, b) => a - b);
|
|
26
|
+
const out: (number | "ellipsis")[] = [];
|
|
27
|
+
for (let i = 0; i < nums.length; i++) {
|
|
28
|
+
const n = nums[i]!;
|
|
29
|
+
if (i > 0 && n - nums[i - 1]! > 1) out.push("ellipsis");
|
|
30
|
+
out.push(n);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ItemBtn({
|
|
36
|
+
children,
|
|
37
|
+
active,
|
|
38
|
+
disabled,
|
|
39
|
+
onClick,
|
|
40
|
+
className,
|
|
41
|
+
}: {
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
active?: boolean;
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
onClick?: () => void;
|
|
46
|
+
className?: string;
|
|
47
|
+
}) {
|
|
48
|
+
return (
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
disabled={disabled}
|
|
52
|
+
onClick={onClick}
|
|
53
|
+
className={cn("inline-flex min-w-7 cursor-pointer items-center justify-center rounded-md px-1.5 text-sm transition-colors", "disabled:cursor-not-allowed disabled:opacity-40", active ? "bg-secondary text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground", className)}
|
|
54
|
+
>
|
|
55
|
+
{children}
|
|
56
|
+
</button>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function Pagination({ current = 1, pageSize = 10, total = 0, onChange, className, disabled, size, itemRender }: PaginationProps) {
|
|
61
|
+
const pages = Math.max(1, Math.ceil(total / pageSize));
|
|
62
|
+
const compact = normalizeSize(size) === "small";
|
|
63
|
+
const h = compact ? controlHeightVar(size) : 28;
|
|
64
|
+
|
|
65
|
+
const wrap = (page: number, type: PaginationItemType, node: ReactNode) => (itemRender ? itemRender(page, type, node) : node);
|
|
66
|
+
|
|
67
|
+
const prev = wrap(
|
|
68
|
+
current - 1,
|
|
69
|
+
"prev",
|
|
70
|
+
<ItemBtn disabled={disabled || current <= 1} onClick={() => onChange?.(current - 1, pageSize)}>
|
|
71
|
+
‹
|
|
72
|
+
</ItemBtn>,
|
|
73
|
+
);
|
|
74
|
+
const next = wrap(
|
|
75
|
+
current + 1,
|
|
76
|
+
"next",
|
|
77
|
+
<ItemBtn disabled={disabled || current >= pages} onClick={() => onChange?.(current + 1, pageSize)}>
|
|
78
|
+
›
|
|
79
|
+
</ItemBtn>,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className={cn("inline-flex items-center gap-0.5", className)} style={{ height: h }}>
|
|
84
|
+
{prev}
|
|
85
|
+
{pageList(current, pages).map((item, i, list) => {
|
|
86
|
+
if (item === "ellipsis") {
|
|
87
|
+
return (
|
|
88
|
+
<span key={`ellipsis-${list[i - 1]}-${list[i + 1]}`} className="px-1 text-sm text-muted-foreground">
|
|
89
|
+
…
|
|
90
|
+
</span>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const node = (
|
|
94
|
+
<ItemBtn active={item === current} disabled={disabled} onClick={() => onChange?.(item, pageSize)}>
|
|
95
|
+
{item}
|
|
96
|
+
</ItemBtn>
|
|
97
|
+
);
|
|
98
|
+
return <span key={item}>{wrap(item, "page", node)}</span>;
|
|
99
|
+
})}
|
|
100
|
+
{next}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|