devnonla-ui 0.1.0 → 0.2.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 (67) hide show
  1. package/README.md +22 -10
  2. package/package.json +2 -2
  3. package/src/alert/Alert.tsx +1 -1
  4. package/src/app/App.tsx +38 -18
  5. package/src/app/context.ts +32 -0
  6. package/src/button/Button.tsx +2 -2
  7. package/src/calendar/Calendar.tsx +4 -4
  8. package/src/chat/AgentPanel.tsx +15 -6
  9. package/src/chat/common/types.ts +27 -2
  10. package/src/chat/common/useAgentStream.ts +78 -12
  11. package/src/chat/common/utils.ts +13 -0
  12. package/src/chat/index.ts +5 -3
  13. package/src/chat/message-ui/ChatAgentMessage.tsx +1 -1
  14. package/src/chat/message-ui/ChatInput.tsx +1 -1
  15. package/src/chat/message-ui/ChatMarkdown.tsx +6 -6
  16. package/src/chat/message-ui/ChatMarkdownTable.tsx +1 -1
  17. package/src/chat/message-ui/ChatThinking.tsx +4 -3
  18. package/src/chat/message-ui/ChatUserMessage.tsx +0 -8
  19. package/src/chat/message-ui/MermaidBlock.tsx +11 -1
  20. package/src/chat/tool-ui/BackgroundTaskToolUI.tsx +2 -1
  21. package/src/chat/tool-ui/CallAgentToolUI.tsx +3 -2
  22. package/src/chat/tool-ui/ChatToolCall.tsx +3 -2
  23. package/src/chat/tool-ui/GetCurrentTimeToolUI.tsx +2 -1
  24. package/src/chat/tool-ui/ReadSkillToolUI.tsx +3 -2
  25. package/src/chat/tool-ui/RunJsToolUI.tsx +3 -2
  26. package/src/chat/tool-ui/WebFetchToolUI.tsx +3 -2
  27. package/src/chat/tool-ui/registry.ts +24 -12
  28. package/src/checkbox/Checkbox.tsx +5 -4
  29. package/src/codeblock/CodeBlock.tsx +1 -0
  30. package/src/datepicker/DatePicker.tsx +15 -9
  31. package/src/desktop/DesktopHeader.tsx +1 -1
  32. package/src/desktop/DesktopIcon.tsx +2 -4
  33. package/src/desktop/DesktopWindow.tsx +19 -5
  34. package/src/desktop/MeadowDesktop.tsx +2 -2
  35. package/src/drawer/Drawer.tsx +8 -6
  36. package/src/dropdown/ContextMenu.tsx +3 -1
  37. package/src/dropdown/Dropdown.tsx +8 -6
  38. package/src/form/Form.tsx +2 -2
  39. package/src/form/FormItem.tsx +2 -2
  40. package/src/form/partials/FormItemHelps.tsx +2 -2
  41. package/src/form/variants/FieldColor.tsx +1 -1
  42. package/src/form/variants/FieldObject.tsx +2 -2
  43. package/src/form/variants/FieldRepeaterItem.tsx +4 -4
  44. package/src/form-layout/FormLayout.tsx +6 -6
  45. package/src/index.ts +20 -6
  46. package/src/input/Input.tsx +11 -10
  47. package/src/lib/sizes.ts +13 -3
  48. package/src/lib/surface.ts +2 -1
  49. package/src/menu/Menu.tsx +2 -2
  50. package/src/message/message.tsx +35 -2
  51. package/src/modal/Modal.tsx +49 -3
  52. package/src/pagination/Pagination.tsx +6 -5
  53. package/src/popconfirm/Popconfirm.tsx +1 -1
  54. package/src/popover/Popover.tsx +6 -8
  55. package/src/scroll/OverlayScroll.tsx +5 -3
  56. package/src/segmented/Segmented.tsx +6 -5
  57. package/src/select/Select.tsx +9 -6
  58. package/src/shimmer/Shimmer.tsx +17 -0
  59. package/src/skeleton/Skeleton.tsx +3 -3
  60. package/src/spin/Spin.tsx +8 -5
  61. package/src/styles.css +81 -27
  62. package/src/switch/Switch.tsx +6 -5
  63. package/src/table/Table.tsx +6 -7
  64. package/src/tag/Tag.tsx +2 -2
  65. package/src/theme.ts +23 -3
  66. package/src/timepicker/TimePicker.tsx +13 -11
  67. package/src/tooltip/Tooltip.tsx +6 -7
@@ -1,5 +1,6 @@
1
1
  import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
2
2
  import type { ReactNode } from "react";
3
+ import { usePopupContainer } from "../app/context";
3
4
  import { cn } from "../lib/cn";
4
5
  import type { MenuItemType, MenuProps } from "./Dropdown";
5
6
  import { menuContentClass, menuIconClass, menuItemClass } from "./menuClasses";
@@ -80,6 +81,7 @@ function MenuItems({
80
81
  }
81
82
 
82
83
  export function ContextMenu({ menu, children, open, onOpenChange, className, overlayClassName, disabled }: ContextMenuProps) {
84
+ const portal = usePopupContainer()?.();
83
85
  if (disabled) return children;
84
86
 
85
87
  const contentClassName = cn(menuContentClass, className, overlayClassName, menu?.className);
@@ -87,7 +89,7 @@ export function ContextMenu({ menu, children, open, onOpenChange, className, ove
87
89
  return (
88
90
  <ContextMenuPrimitive.Root modal open={open} onOpenChange={onOpenChange}>
89
91
  <ContextMenuPrimitive.Trigger asChild>{children}</ContextMenuPrimitive.Trigger>
90
- <ContextMenuPrimitive.Portal>
92
+ <ContextMenuPrimitive.Portal container={portal}>
91
93
  <ContextMenuPrimitive.Content collisionPadding={8} className={contentClassName} style={menu?.style}>
92
94
  <MenuItems items={menu?.items ?? []} onClick={menu?.onClick} contentClassName={contentClassName} />
93
95
  </ContextMenuPrimitive.Content>
@@ -1,5 +1,6 @@
1
1
  import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
2
2
  import { type CSSProperties, type MouseEvent, type ReactNode, useState } from "react";
3
+ import { usePopupContainer } from "../app/context";
3
4
  import { cn } from "../lib/cn";
4
5
  import { type PopperPlacement, placementToRadix } from "../lib/placement";
5
6
  import { menuContentClass, menuIconClass, menuItemClass } from "./menuClasses";
@@ -34,7 +35,7 @@ export type DropdownProps = {
34
35
  className?: string;
35
36
  overlayClassName?: string;
36
37
  disabled?: boolean;
37
- /** antd 5.25+ alias — accepted, no-op (Radix unmounts when closed). */
38
+ /** Alias — accepted, no-op (Radix unmounts when closed). */
38
39
  destroyOnHidden?: boolean;
39
40
  classNames?: { root?: string; overlay?: string };
40
41
  };
@@ -51,7 +52,7 @@ function itemKey(item: MenuItemType, i: number) {
51
52
  return item.key ?? (item.type === "divider" ? `divider-${i}` : `item-${i}`);
52
53
  }
53
54
 
54
- function MenuItems({ items, onClick }: { items: (MenuItemType | null | undefined)[]; onClick?: MenuProps["onClick"] }) {
55
+ function MenuItems({ items, onClick, portal }: { items: (MenuItemType | null | undefined)[]; onClick?: MenuProps["onClick"]; portal?: HTMLElement }) {
55
56
  return (
56
57
  <>
57
58
  {items.map((item, i) => {
@@ -68,9 +69,9 @@ function MenuItems({ items, onClick }: { items: (MenuItemType | null | undefined
68
69
  <span className="min-w-0 flex-1">{item.label}</span>
69
70
  <ChevronRight />
70
71
  </DropdownMenu.SubTrigger>
71
- <DropdownMenu.Portal>
72
+ <DropdownMenu.Portal container={portal}>
72
73
  <DropdownMenu.SubContent sideOffset={4} className={cn(menuContentClass, "nonla-popper")}>
73
- <MenuItems items={item.children} onClick={onClick} />
74
+ <MenuItems items={item.children} onClick={onClick} portal={portal} />
74
75
  </DropdownMenu.SubContent>
75
76
  </DropdownMenu.Portal>
76
77
  </DropdownMenu.Sub>
@@ -98,6 +99,7 @@ function MenuItems({ items, onClick }: { items: (MenuItemType | null | undefined
98
99
 
99
100
  export function Dropdown({ menu, children, trigger = ["click"], open, onOpenChange, placement = "bottomLeft", className, overlayClassName, disabled }: DropdownProps) {
100
101
  const { side, align } = placementToRadix(placement);
102
+ const portal = usePopupContainer()?.();
101
103
  const hover = trigger.includes("hover");
102
104
  const contextMenu = trigger.includes("contextMenu");
103
105
  const click = trigger.includes("click") || (!hover && !contextMenu);
@@ -119,7 +121,7 @@ export function Dropdown({ menu, children, trigger = ["click"], open, onOpenChan
119
121
  <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
122
  {children}
121
123
  </DropdownMenu.Trigger>
122
- <DropdownMenu.Portal>
124
+ <DropdownMenu.Portal container={portal}>
123
125
  <DropdownMenu.Content
124
126
  side={side}
125
127
  align={align === "center" ? "start" : align}
@@ -129,7 +131,7 @@ export function Dropdown({ menu, children, trigger = ["click"], open, onOpenChan
129
131
  onMouseEnter={hover ? () => setIsOpen(true) : undefined}
130
132
  onMouseLeave={hover ? () => setIsOpen(false) : undefined}
131
133
  >
132
- <MenuItems items={menu?.items ?? []} onClick={menu?.onClick} />
134
+ <MenuItems items={menu?.items ?? []} onClick={menu?.onClick} portal={portal} />
133
135
  </DropdownMenu.Content>
134
136
  </DropdownMenu.Portal>
135
137
  </DropdownMenu.Root>
package/src/form/Form.tsx CHANGED
@@ -20,10 +20,10 @@ export type FormProps<T extends FieldValues = FieldValues> = {
20
20
  function evalCondition(fieldValue: unknown, operator: string, compareValue: unknown): boolean {
21
21
  switch (operator) {
22
22
  case "==":
23
- // eslint-disable-next-line eqeqeq
23
+ // biome-ignore lint/suspicious/noDoubleEquals: form condition operator is loose equality
24
24
  return fieldValue == compareValue;
25
25
  case "!=":
26
- // eslint-disable-next-line eqeqeq
26
+ // biome-ignore lint/suspicious/noDoubleEquals: form condition operator is loose equality
27
27
  return fieldValue != compareValue;
28
28
  case ">=":
29
29
  return (fieldValue as number) >= (compareValue as number);
@@ -111,10 +111,10 @@ export function FormItem(props: FormItemProps) {
111
111
  </svg>
112
112
  </button>
113
113
  ) : null}
114
- <label className="text-sm text-foreground">
114
+ <div className="text-sm text-foreground">
115
115
  {label}
116
116
  {required ? <span className="ml-1 text-destructive">*</span> : null}
117
- </label>
117
+ </div>
118
118
  </div>
119
119
  ) : null}
120
120
 
@@ -23,8 +23,8 @@ export function FormItemHelps({ items, className }: FormItemHelpsProps) {
23
23
  if (!items?.length) return null;
24
24
  return (
25
25
  <div className={cn("mt-1 flex flex-col gap-1 pl-2.75", className)}>
26
- {items.map((item, index) => (
27
- <FormItemHelp key={`${index}-${item.text}`} text={item.text} className={item.className} iconClassName={item.iconClassName} />
26
+ {items.map((item) => (
27
+ <FormItemHelp key={item.text} text={item.text} className={item.className} iconClassName={item.iconClassName} />
28
28
  ))}
29
29
  </div>
30
30
  );
@@ -24,7 +24,7 @@ export function FieldColor({ field, options, status }: Props) {
24
24
  trigger="click"
25
25
  placement="bottom"
26
26
  content={
27
- <div className="flex flex-col gap-2 p-1 min-w-[180px]">
27
+ <div className="flex flex-col gap-2 p-1 min-w-45">
28
28
  <input
29
29
  type="color"
30
30
  value={hex}
@@ -13,11 +13,11 @@ export function FieldObject({ control, name, childItems }: Props) {
13
13
  const prefix = fieldNameOf(name);
14
14
  return (
15
15
  <div className="grid grid-cols-12 gap-x-4">
16
- {(childItems ?? []).map((child, index) => {
16
+ {(childItems ?? []).map((child) => {
17
17
  const childName = fieldNameOf(child.name);
18
18
  return (
19
19
  <FormItem
20
- key={`${prefix}.${childName}-${index}`}
20
+ key={`${prefix}.${childName}`}
21
21
  {...child}
22
22
  name={`${prefix}.${childName}`}
23
23
  control={control}
@@ -17,7 +17,7 @@ export function FieldRepeaterItem({ index, remove, namePrefix, childItems, contr
17
17
 
18
18
  return (
19
19
  <div className="relative">
20
- <div className={`absolute bottom-0 left-0 border-l border-dashed border-border ${index === 0 ? "top-0" : "top-[-20px]"}`} />
20
+ <div className={`absolute bottom-0 left-0 border-l border-dashed border-border ${index === 0 ? "top-0" : "-top-5"}`} />
21
21
 
22
22
  <div className="flex items-center gap-2">
23
23
  <div className="flex flex-1 items-center gap-2">
@@ -37,7 +37,7 @@ export function FieldRepeaterItem({ index, remove, namePrefix, childItems, contr
37
37
  <button
38
38
  type="button"
39
39
  aria-label="Remove item"
40
- className="inline-flex size-5 items-center justify-center rounded border-0 bg-muted text-muted-foreground cursor-pointer hover:bg-destructive hover:text-[var(--destructive-foreground)]"
40
+ className="inline-flex size-5 items-center justify-center rounded border-0 bg-muted text-muted-foreground cursor-pointer hover:bg-destructive hover:text-destructive-foreground"
41
41
  onClick={() => remove(index)}
42
42
  >
43
43
  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
@@ -47,11 +47,11 @@ export function FieldRepeaterItem({ index, remove, namePrefix, childItems, contr
47
47
  </div>
48
48
 
49
49
  <div className={`mt-2 grid grid-cols-12 gap-x-4 overflow-hidden pl-8 ${collapsed ? "max-h-0" : ""}`}>
50
- {(childItems ?? []).map((child, childIndex) => {
50
+ {(childItems ?? []).map((child) => {
51
51
  const childName = fieldNameOf(child.name);
52
52
  return (
53
53
  <FormItem
54
- key={`${namePrefix}.${childName}-${childIndex}`}
54
+ key={`${namePrefix}.${childName}`}
55
55
  {...child}
56
56
  name={`${namePrefix}.${childName}`}
57
57
  control={control}
@@ -1,8 +1,8 @@
1
- import { type HTMLAttributes, type ReactNode } from "react";
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
2
  import { cn } from "../lib/cn";
3
3
 
4
4
  export type FormLayoutProps = HTMLAttributes<HTMLDivElement> & {
5
- /** antd Form layout — only vertical is used in this app. */
5
+ /** Form layout — only vertical is used in this app. */
6
6
  layout?: "horizontal" | "vertical" | "inline";
7
7
  children?: ReactNode;
8
8
  };
@@ -25,7 +25,7 @@ function FormRoot({ layout = "vertical", className, children, ...rest }: FormLay
25
25
  export type FormLayoutItemProps = {
26
26
  label?: ReactNode;
27
27
  required?: boolean;
28
- /** antd `extra` — help text under the control. */
28
+ /** Help text under the control. */
29
29
  extra?: ReactNode;
30
30
  help?: ReactNode;
31
31
  validateStatus?: "success" | "warning" | "error" | "validating";
@@ -53,10 +53,10 @@ function FormItem({
53
53
  )}
54
54
  >
55
55
  {label != null && label !== false ? (
56
- <label className={cn("text-sm text-foreground", layout === "horizontal" && "pt-1.5 shrink-0")}>
56
+ <div className={cn("text-sm text-foreground", layout === "horizontal" && "pt-1.5 shrink-0")}>
57
57
  {label}
58
58
  {required ? <span className="ml-1 text-destructive">*</span> : null}
59
- </label>
59
+ </div>
60
60
  ) : null}
61
61
  <div className="min-w-0 flex-1">
62
62
  {children}
@@ -69,6 +69,6 @@ function FormItem({
69
69
  );
70
70
  }
71
71
 
72
- /** Layout-only Form (antd Form / Form.Item drop-in). Schema forms use `SchemaForm`. */
72
+ /** Layout-only Form (`Form` / `Form.Item`). Schema forms use `SchemaForm`. */
73
73
  export const Form = Object.assign(FormRoot, { Item: FormItem });
74
74
  export { FormItem };
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { App, useAppConfig } from "./app/App";
1
+ export { App, useApp, useAppConfig, usePopupContainer, useToken } from "./app/App";
2
2
  export type { AppProps, NonlaAppConfig } from "./app/App";
3
3
 
4
4
  export { Button } from "./button/Button";
@@ -10,7 +10,7 @@ export { Input, TextArea, InputNumber } from "./input/Input";
10
10
  export type { InputProps, TextAreaProps, InputNumberProps, InputSize, TextAreaRef, PasswordProps } from "./input/Input";
11
11
  export { SearchInput } from "./input/SearchInput";
12
12
  export type { SearchInputProps } from "./input/SearchInput";
13
- /** antd `InputRef` compatibility — native input element. */
13
+ /** Native input element ref. */
14
14
  export type InputRef = HTMLInputElement;
15
15
 
16
16
  export { Select, SelectOption } from "./select/Select";
@@ -55,7 +55,7 @@ export type {
55
55
  IFormItemHelpProps,
56
56
  } from "./form";
57
57
 
58
- /** Layout-only Form + Form.Item (antd drop-in for labeled fields). */
58
+ /** Layout-only Form + Form.Item for labeled fields. */
59
59
  export { Form } from "./form-layout/FormLayout";
60
60
  export type { FormLayoutProps, FormLayoutItemProps } from "./form-layout/FormLayout";
61
61
 
@@ -102,6 +102,9 @@ export type { SpinProps, SpinVariant } from "./spin/Spin";
102
102
  export { Skeleton } from "./skeleton/Skeleton";
103
103
  export type { SkeletonProps } from "./skeleton/Skeleton";
104
104
 
105
+ export { Shimmer } from "./shimmer/Shimmer";
106
+ export type { ShimmerProps } from "./shimmer/Shimmer";
107
+
105
108
  export { Segmented } from "./segmented/Segmented";
106
109
  export type { SegmentedProps, SegmentedOption } from "./segmented/Segmented";
107
110
 
@@ -143,9 +146,14 @@ export {
143
146
  prettyJson,
144
147
  isCallAgentToolName,
145
148
  parseCallAgentToolTargetId,
149
+ matchesToolName,
150
+ matchesToolHook,
146
151
  parseBgTaskRef,
147
152
  formatBgElapsed,
148
153
  resolveToolUI,
154
+ builtinToolUis,
155
+ matchesToolUIName,
156
+ isToolRunning,
149
157
  CallAgentToolUI,
150
158
  WebFetchToolUI,
151
159
  GetCurrentTimeToolUI,
@@ -173,20 +181,26 @@ export type {
173
181
  AgentStreamRequest,
174
182
  AgentPanelEndpoint,
175
183
  AgentToolAction,
184
+ AgentToolHook,
185
+ AgentToolCallEvent,
186
+ AgentToolResultEvent,
187
+ AgentToolNameMatch,
176
188
  ToolUIProps,
177
189
  ChatToolMessage,
190
+ AgentToolUI,
191
+ AgentToolUIName,
178
192
  ChatBgTask,
179
193
  BackgroundTasksBarProps,
180
194
  } from "./chat";
181
195
 
182
196
  export { cn } from "./lib/cn";
183
197
  export { glassSurfaceClass, glassOverlayClass, meadowSurfaceClass } from "./lib/surface";
184
- export { CONTROL_SIZES, normalizeSize, getSizeTokens, controlHeightVar, controlRadiusVar, controlStatusClass } from "./lib/sizes";
198
+ export { CONTROL_SIZES, normalizeSize, getSizeTokens, controlHeightVar, controlRadiusVar, controlStatusClass, useControlSize } from "./lib/sizes";
185
199
  export type { ControlSize, CanonicalSize, ControlSizeTokens } from "./lib/sizes";
186
200
  export { placementToRadix } from "./lib/placement";
187
201
  export type { PopperPlacement } from "./lib/placement";
188
- export { NONLA_THEME_KNOBS, NONLA_THEME_KEYS, applyNonlaTheme } from "./theme";
189
- export type { NonlaThemeKnob, NonlaThemeKnobName, NonlaThemeColorName, NonlaThemeColors, NonlaThemeConfig } from "./theme";
202
+ export { NONLA_THEME_KNOBS, NONLA_THEME_KEYS, applyNonlaTheme, getDesignToken } from "./theme";
203
+ export type { NonlaThemeKnob, NonlaThemeKnobName, NonlaThemeColorName, NonlaThemeColors, NonlaThemeConfig, NonlaTokenSnapshot } from "./theme";
190
204
 
191
205
  export { FluentIcon } from "./icon/FluentIcon";
192
206
  export {
@@ -1,6 +1,6 @@
1
1
  import { type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type ReactNode, type TextareaHTMLAttributes, forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
2
2
  import { cn } from "../lib/cn";
3
- import { type ControlSize, controlFieldFocusBorder, controlFieldStyle, controlFieldSurface, controlFieldTransition, controlHeightVar, controlRadiusVar, controlStatusClass, getSizeTokens, normalizeSize } from "../lib/sizes";
3
+ import { type ControlSize, controlFieldFocusBorder, controlFieldStyle, controlFieldSurface, controlFieldTransition, controlHeightVar, controlRadiusVar, controlStatusClass, getSizeTokens, useControlSize } from "../lib/sizes";
4
4
 
5
5
  export type InputSize = ControlSize;
6
6
 
@@ -11,7 +11,7 @@ export type InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "p
11
11
  suffix?: ReactNode;
12
12
  allowClear?: boolean;
13
13
  variant?: "outlined" | "borderless" | "filled";
14
- /** antd — fires on Enter (ignored while composing). */
14
+ /** Fires on Enter (ignored while composing). */
15
15
  onPressEnter?: (e: KeyboardEvent<HTMLInputElement>) => void;
16
16
  };
17
17
 
@@ -24,7 +24,7 @@ function variantClass(variant: InputProps["variant"]) {
24
24
  }
25
25
 
26
26
  const InputRoot = forwardRef<HTMLInputElement, InputProps>(function Input({ className, size, status, prefix, suffix, allowClear, variant = "outlined", disabled, value, onChange, onPressEnter, onKeyDown, style, ...rest }, ref) {
27
- const fieldStyle = controlFieldStyle(size);
27
+ const fieldStyle = controlFieldStyle(useControlSize(size));
28
28
  const showClear = allowClear && !disabled && value != null && String(value).length > 0;
29
29
  const wrapped = Boolean(prefix || suffix || showClear);
30
30
 
@@ -72,13 +72,13 @@ export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
72
72
  size?: ControlSize;
73
73
  };
74
74
 
75
- /** antd-compatible ref used by chat InputArea (`resizableTextArea.textArea`). */
75
+ /** Ref used by chat InputArea (`resizableTextArea.textArea`). */
76
76
  export type TextAreaRef = HTMLTextAreaElement & {
77
77
  resizableTextArea?: { textArea: HTMLTextAreaElement };
78
78
  };
79
79
 
80
80
  const TextArea = forwardRef<TextAreaRef, TextAreaProps>(function TextArea({ className, status, variant = "outlined", autoSize, rows, style, size, onChange, value, ...rest }, ref) {
81
- const tok = getSizeTokens(size);
81
+ const tok = getSizeTokens(useControlSize(size));
82
82
  const innerRef = useRef<HTMLTextAreaElement | null>(null);
83
83
  const minRows = typeof autoSize === "object" ? (autoSize.minRows ?? 1) : autoSize ? 1 : undefined;
84
84
  const maxRows = typeof autoSize === "object" ? autoSize.maxRows : undefined;
@@ -139,7 +139,7 @@ export type InputNumberProps = Omit<InputProps, "type" | "onChange" | "value" |
139
139
  min?: number;
140
140
  max?: number;
141
141
  step?: number;
142
- /** Show antd-style up/down handlers (default true). */
142
+ /** Show up/down handlers (default true). */
143
143
  controls?: boolean;
144
144
  /** Precision after decimal; omit to keep free typing. */
145
145
  precision?: number;
@@ -175,8 +175,9 @@ const InputNumber = forwardRef<HTMLInputElement, InputNumberProps>(function Inpu
175
175
  const [text, setText] = useState(() => (numeric == null ? "" : String(numeric)));
176
176
  const [focused, setFocused] = useState(false);
177
177
  const inputRef = useRef<HTMLInputElement | null>(null);
178
- const tok = getSizeTokens(size);
179
- const fieldStyle = controlFieldStyle(size);
178
+ const resolvedSize = useControlSize(size);
179
+ const tok = getSizeTokens(resolvedSize);
180
+ const fieldStyle = controlFieldStyle(resolvedSize);
180
181
 
181
182
  // Sync display from external value when not editing
182
183
  useEffect(() => {
@@ -215,7 +216,7 @@ const InputNumber = forwardRef<HTMLInputElement, InputNumberProps>(function Inpu
215
216
 
216
217
  const atMin = numeric != null && min != null && numeric <= min;
217
218
  const atMax = numeric != null && max != null && numeric >= max;
218
- const handlerW = normalizeSize(size) === "small" ? 18 : 22;
219
+ const handlerW = resolvedSize === "small" ? 18 : 22;
219
220
 
220
221
  const setRefs = (node: HTMLInputElement | null) => {
221
222
  inputRef.current = node;
@@ -254,7 +255,7 @@ const InputNumber = forwardRef<HTMLInputElement, InputNumberProps>(function Inpu
254
255
  onChange={(e) => {
255
256
  const raw = e.target.value;
256
257
  setText(raw);
257
- // Live update when parseable (antd-like); keep typing free otherwise
258
+ // Live update when parseable; keep typing free otherwise
258
259
  if (raw === "" || raw === "-" || raw === "." || raw === "-.") return;
259
260
  const n = parseRaw(raw);
260
261
  if (n != null) {
package/src/lib/sizes.ts CHANGED
@@ -1,8 +1,9 @@
1
- import type { CSSProperties } from "react";
1
+ import { createContext, useContext, type CSSProperties } from "react";
2
2
 
3
3
  /**
4
4
  * Canonical control sizes for NonlaUI.
5
- * Height + radius live as `--nonla-*` CSS knobs (theme). Other metrics stay here.
5
+ * Live height / radius come from `--nonla-height*` / `--nonla-radius*` (seed).
6
+ * Numbers here are fallbacks for padding / font / icon — keep in sync with CSS 24 / 32 / 40.
6
7
  * Radius small/large = `--nonla-radius` ± 2px.
7
8
  */
8
9
  export const CONTROL_SIZES = {
@@ -45,7 +46,7 @@ export type ControlSize = CanonicalSize | "middle" | "medium" | "xs";
45
46
 
46
47
  export type ControlSizeTokens = (typeof CONTROL_SIZES)[CanonicalSize];
47
48
 
48
- /** Map legacy / antd aliases → small | default | large */
49
+ /** Map legacy aliases → small | default | large */
49
50
  export function normalizeSize(size: ControlSize | undefined): CanonicalSize {
50
51
  if (size === "small" || size === "xs") return "small";
51
52
  if (size === "large") return "large";
@@ -53,6 +54,15 @@ export function normalizeSize(size: ControlSize | undefined): CanonicalSize {
53
54
  return "default";
54
55
  }
55
56
 
57
+ /** App `componentSize` — prop on the control wins, then this. */
58
+ export const ControlSizeContext = createContext<CanonicalSize | undefined>(undefined);
59
+
60
+ /** Resolve size: control prop → App `componentSize` → default. */
61
+ export function useControlSize(size?: ControlSize): CanonicalSize {
62
+ const fromApp = useContext(ControlSizeContext);
63
+ return normalizeSize(size ?? fromApp);
64
+ }
65
+
56
66
  export function getSizeTokens(size: ControlSize | undefined): ControlSizeTokens {
57
67
  return CONTROL_SIZES[normalizeSize(size)];
58
68
  }
@@ -2,7 +2,8 @@
2
2
  export const glassSurfaceClass = "nonla-glass";
3
3
 
4
4
  /** Floating overlay chrome (Popover / Dropdown / Select / pickers / Tooltip). */
5
- export const glassOverlayClass = "z-[9999] rounded-xl nonla-glass outline-none nonla-popper";
5
+ export const glassOverlayClass =
6
+ "nonla-popup-layer z-[var(--nonla-z-popup,1050)] rounded-xl nonla-glass outline-none nonla-popper";
6
7
 
7
8
  /** Meadow menus — same panel as overlays. */
8
9
  export const meadowSurfaceClass = "rounded-xl nonla-glass";
package/src/menu/Menu.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { type ButtonHTMLAttributes, type ReactNode } from "react";
1
+ import type { ButtonHTMLAttributes, ReactNode } from "react";
2
2
  import { FluentIcon } from "../icon/FluentIcon";
3
3
  import { cn } from "../lib/cn";
4
4
  import type { PopperPlacement } from "../lib/placement";
@@ -20,7 +20,7 @@ export function Menu({ open, onOpenChange, trigger, children, contentClassName,
20
20
  onOpenChange={onOpenChange}
21
21
  trigger="click"
22
22
  placement={placement}
23
- contentClassName={cn("w-[220px] p-1", contentClassName)}
23
+ contentClassName={cn("w-55 p-1", contentClassName)}
24
24
  content={<div className="flex flex-col gap-px">{children}</div>}
25
25
  >
26
26
  {trigger}
@@ -1,4 +1,4 @@
1
- import { type CSSProperties, type ReactNode } from "react";
1
+ import { type CSSProperties, type ReactNode, useLayoutEffect, useState } from "react";
2
2
  import { createRoot, type Root } from "react-dom/client";
3
3
 
4
4
  export type MessageType = "success" | "error" | "info" | "warning" | "loading";
@@ -117,8 +117,19 @@ function MessageList({ list }: { list: Active[] }) {
117
117
  );
118
118
  }
119
119
 
120
+ type MessageRenderer = (list: Active[]) => void;
121
+ let renderer: MessageRenderer | null = null;
122
+
123
+ function dropFallbackHost() {
124
+ if (!hostEl) return;
125
+ root?.unmount();
126
+ hostEl.remove();
127
+ hostEl = null;
128
+ root = null;
129
+ }
130
+
120
131
  function ensureHost() {
121
- if (hostEl && root) return;
132
+ if (renderer || (hostEl && root)) return;
122
133
  hostEl = document.createElement("div");
123
134
  hostEl.className = "nonla-message-host";
124
135
  document.body.appendChild(hostEl);
@@ -126,10 +137,32 @@ function ensureHost() {
126
137
  }
127
138
 
128
139
  function render() {
140
+ if (renderer) {
141
+ renderer(items);
142
+ return;
143
+ }
129
144
  ensureHost();
130
145
  root?.render(<MessageList list={items} />);
131
146
  }
132
147
 
148
+ /** Mounted by `App` so toasts inherit theme. Falls back to `document.body` if no App. */
149
+ export function MessageHolder() {
150
+ const [list, setList] = useState<Active[]>(items);
151
+ useLayoutEffect(() => {
152
+ dropFallbackHost();
153
+ renderer = setList;
154
+ setList(items);
155
+ return () => {
156
+ renderer = null;
157
+ };
158
+ }, []);
159
+ return (
160
+ <div className="nonla-message-host" aria-live="polite" aria-relevant="additions">
161
+ <MessageList list={list} />
162
+ </div>
163
+ );
164
+ }
165
+
133
166
  function beginLeave(id: string) {
134
167
  const found = items.find((i) => i.id === id);
135
168
  if (!found || found.leaving) return;
@@ -4,10 +4,12 @@ import {
4
4
  type CSSProperties,
5
5
  type ReactNode,
6
6
  useEffect,
7
+ useLayoutEffect,
7
8
  useRef,
8
9
  useState,
9
10
  } from "react";
10
11
  import { createRoot, type Root } from "react-dom/client";
12
+ import { usePopupContainer } from "../app/context";
11
13
  import { Button } from "../button/Button";
12
14
  import { cn } from "../lib/cn";
13
15
  import { glassSurfaceClass } from "../lib/surface";
@@ -37,7 +39,7 @@ export type ModalProps = {
37
39
  closable?: boolean;
38
40
  maskClosable?: boolean;
39
41
  className?: string;
40
- /** Root positioning (antd `style={{ top }}`). */
42
+ /** Root positioning (`style={{ top }}`). */
41
43
  style?: CSSProperties;
42
44
  styles?: { body?: CSSProperties; content?: CSSProperties; header?: CSSProperties; footer?: CSSProperties; container?: CSSProperties };
43
45
  onOpenChange?: (open: boolean) => void;
@@ -154,6 +156,8 @@ function ModalView({
154
156
  if (!isOpen) finishExit();
155
157
  };
156
158
 
159
+ const portal = usePopupContainer()?.();
160
+
157
161
  return (
158
162
  <Dialog.Root
159
163
  open={isOpen}
@@ -166,7 +170,7 @@ function ModalView({
166
170
  }}
167
171
  >
168
172
  {isOpen || present ? (
169
- <Dialog.Portal>
173
+ <Dialog.Portal container={portal}>
170
174
  <Dialog.Overlay className="nonla-modal-overlay" />
171
175
  <Dialog.Content
172
176
  className={cn("nonla-modal-content", glassSurfaceClass, className)}
@@ -210,6 +214,10 @@ function ConfirmHost({ initial, onDone }: { initial: ModalConfirmProps; onDone:
210
214
  const [props, setProps] = useState(initial);
211
215
  const [loading, setLoading] = useState(false);
212
216
 
217
+ useEffect(() => {
218
+ setProps(initial);
219
+ }, [initial]);
220
+
213
221
  useEffect(() => {
214
222
  (ConfirmHost as unknown as { _update?: (p: Partial<ModalConfirmProps>) => void })._update = (p) => setProps((prev) => ({ ...prev, ...p }));
215
223
  }, []);
@@ -253,7 +261,12 @@ function ConfirmHost({ initial, onDone }: { initial: ModalConfirmProps; onDone:
253
261
  );
254
262
  }
255
263
 
256
- function mountConfirm(props: ModalConfirmProps): ConfirmHandle {
264
+ type ConfirmJob = { id: number; props: ModalConfirmProps };
265
+ type ConfirmOpener = (props: ModalConfirmProps) => ConfirmHandle;
266
+
267
+ let openConfirm: ConfirmOpener | null = null;
268
+
269
+ function mountFallback(props: ModalConfirmProps): ConfirmHandle {
257
270
  const el = document.createElement("div");
258
271
  document.body.appendChild(el);
259
272
  const root: Root = createRoot(el);
@@ -268,6 +281,39 @@ function mountConfirm(props: ModalConfirmProps): ConfirmHandle {
268
281
  };
269
282
  }
270
283
 
284
+ function mountConfirm(props: ModalConfirmProps): ConfirmHandle {
285
+ return openConfirm ? openConfirm(props) : mountFallback(props);
286
+ }
287
+
288
+ /** Mounted by `App` so `Modal.confirm` shares theme / context. */
289
+ export function ConfirmHolder() {
290
+ const [jobs, setJobs] = useState<ConfirmJob[]>([]);
291
+ const seq = useRef(0);
292
+
293
+ useLayoutEffect(() => {
294
+ openConfirm = (props) => {
295
+ const id = ++seq.current;
296
+ setJobs((list) => [...list, { id, props }]);
297
+ return {
298
+ destroy: () => setJobs((list) => list.filter((job) => job.id !== id)),
299
+ update: (p) =>
300
+ setJobs((list) => list.map((job) => (job.id === id ? { ...job, props: { ...job.props, ...p } } : job))),
301
+ };
302
+ };
303
+ return () => {
304
+ openConfirm = null;
305
+ };
306
+ }, []);
307
+
308
+ return (
309
+ <>
310
+ {jobs.map((job) => (
311
+ <ConfirmHost key={job.id} initial={job.props} onDone={() => setJobs((list) => list.filter((j) => j.id !== job.id))} />
312
+ ))}
313
+ </>
314
+ );
315
+ }
316
+
271
317
  export const Modal = Object.assign(ModalView, {
272
318
  confirm: (props: ModalConfirmProps) => mountConfirm(props),
273
319
  info: (props: ModalConfirmProps) => mountConfirm(props),
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { cn } from "../lib/cn";
3
- import { type ControlSize, controlHeightVar, normalizeSize } from "../lib/sizes";
3
+ import { type ControlSize, controlHeightVar, useControlSize } from "../lib/sizes";
4
4
 
5
5
  export type PaginationItemType = "page" | "prev" | "next" | "jump-prev" | "jump-next";
6
6
 
@@ -20,8 +20,8 @@ export type PaginationProps = {
20
20
  function pageList(current: number, pages: number): (number | "ellipsis")[] {
21
21
  if (pages <= 7) return Array.from({ length: pages }, (_, i) => i + 1);
22
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));
23
+ if (current <= 3) for (const n of [2, 3, 4]) set.add(n);
24
+ if (current >= pages - 2) for (const n of [pages - 1, pages - 2, pages - 3]) set.add(n);
25
25
  const nums = [...set].filter((n) => n >= 1 && n <= pages).sort((a, b) => a - b);
26
26
  const out: (number | "ellipsis")[] = [];
27
27
  for (let i = 0; i < nums.length; i++) {
@@ -58,9 +58,10 @@ function ItemBtn({
58
58
  }
59
59
 
60
60
  export function Pagination({ current = 1, pageSize = 10, total = 0, onChange, className, disabled, size, itemRender }: PaginationProps) {
61
+ const resolvedSize = useControlSize(size);
62
+ const compact = resolvedSize === "small";
63
+ const h = compact ? controlHeightVar(resolvedSize) : 28;
61
64
  const pages = Math.max(1, Math.ceil(total / pageSize));
62
- const compact = normalizeSize(size) === "small";
63
- const h = compact ? controlHeightVar(size) : 28;
64
65
 
65
66
  const wrap = (page: number, type: PaginationItemType, node: ReactNode) => (itemRender ? itemRender(page, type, node) : node);
66
67
 
@@ -1,7 +1,7 @@
1
1
  import { type CSSProperties, type ReactNode, useState } from "react";
2
2
  import { Button } from "../button/Button";
3
3
  import { cn } from "../lib/cn";
4
- import { type PopperPlacement } from "../lib/placement";
4
+ import type { PopperPlacement } from "../lib/placement";
5
5
  import { Popover } from "../popover/Popover";
6
6
 
7
7
  export type PopconfirmProps = {