devnonla-ui 0.3.0 → 0.4.1

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 (48) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -1
  3. package/src/app/App.tsx +4 -4
  4. package/src/button/Button.tsx +1 -1
  5. package/src/button/ButtonCopy.tsx +11 -21
  6. package/src/calendar/Calendar.tsx +1 -1
  7. package/src/chat/AgentPanel.tsx +3 -3
  8. package/src/chat/index.ts +35 -51
  9. package/src/chat/message-ui/ChatError.tsx +2 -4
  10. package/src/chat/message-ui/ChatInput.tsx +5 -9
  11. package/src/chat/message-ui/ChatThinking.tsx +2 -3
  12. package/src/chat/message-ui/ChatUserMessage.tsx +4 -5
  13. package/src/chat/message-ui/ChatWelcome.tsx +3 -8
  14. package/src/chat/message-ui/MermaidBlock.tsx +7 -47
  15. package/src/chat/tool-ui/BackgroundTaskToolUI.tsx +8 -11
  16. package/src/chat/tool-ui/BackgroundTasksBar.tsx +13 -14
  17. package/src/chat/tool-ui/CallAgentToolUI.tsx +10 -4
  18. package/src/chat/tool-ui/ChatToolCall.tsx +11 -17
  19. package/src/chat/tool-ui/GetCurrentTimeToolUI.tsx +2 -2
  20. package/src/chat/tool-ui/ReadSkillToolUI.tsx +3 -3
  21. package/src/chat/tool-ui/RunJsToolUI.tsx +2 -2
  22. package/src/chat/tool-ui/ToolUiTrailing.tsx +3 -7
  23. package/src/chat/tool-ui/WebFetchToolUI.tsx +22 -13
  24. package/src/checkbox/Checkbox.tsx +1 -1
  25. package/src/codeblock/CodeBlock.tsx +17 -19
  26. package/src/colorpicker/ColorPicker.tsx +7 -7
  27. package/src/datepicker/DatePicker.tsx +3 -3
  28. package/src/desktop/DesktopHeader.tsx +15 -20
  29. package/src/desktop/DesktopIcon.tsx +3 -9
  30. package/src/desktop/DesktopWindow.tsx +54 -30
  31. package/src/desktop/MeadowDesktop.tsx +1 -1
  32. package/src/form/Form.tsx +1 -1
  33. package/src/form/FormItem.tsx +2 -2
  34. package/src/form/index.ts +23 -24
  35. package/src/index.ts +167 -199
  36. package/src/input/Input.tsx +14 -12
  37. package/src/input/SearchInput.tsx +5 -2
  38. package/src/lib/sizes.ts +1 -1
  39. package/src/modal/Modal.tsx +3 -0
  40. package/src/pagination/Pagination.tsx +528 -51
  41. package/src/scroll/OverlayScroll.tsx +58 -37
  42. package/src/select/Select.tsx +2 -2
  43. package/src/spin/Spin.tsx +14 -17
  44. package/src/splitter/Splitter.tsx +3 -3
  45. package/src/styles.css +13 -11
  46. package/src/switch/Switch.tsx +1 -1
  47. package/src/table/Table.tsx +43 -18
  48. package/src/timepicker/TimePicker.tsx +2 -2
@@ -1,8 +1,8 @@
1
1
  import { type ReactNode, useState } from "react";
2
+ import { FluentIcon } from "../../icon/FluentIcon";
2
3
  import { cn } from "../../lib/cn";
3
4
  import { Shimmer } from "../../shimmer/Shimmer";
4
5
  import { formatToolName, hasMeaningfulInput, prettyJson } from "../common/utils";
5
- import { ChatSpinner } from "../message-ui/ChatSpinner";
6
6
  import { ToolUiTrailing } from "./ToolUiTrailing";
7
7
 
8
8
  export type ChatToolCallProps = {
@@ -11,7 +11,7 @@ export type ChatToolCallProps = {
11
11
  toolInput?: unknown;
12
12
  toolOutput?: unknown;
13
13
  toolError?: boolean | string;
14
- /** Show running spinner (pending execution). */
14
+ /** Pending execution label shimmers, no spinner. */
15
15
  running?: boolean;
16
16
  /** Optional icon node (left of label). */
17
17
  icon?: ReactNode;
@@ -28,44 +28,38 @@ export function ChatToolCall({ toolName = "Tool", label, toolInput, toolOutput,
28
28
  const [open, setOpen] = useState(defaultOpen);
29
29
  const displayLabel = label ?? formatToolName(toolName);
30
30
 
31
- const identityIcon = icon ?? (
32
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="shrink-0 text-muted-foreground" aria-hidden>
33
- <path d="M8 7h8M8 12h8M8 17h5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
34
- <rect x="4" y="4" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="1.75" />
35
- </svg>
36
- );
31
+ const identityIcon = icon ?? <FluentIcon name="board-24" size={13} className="shrink-0" />;
37
32
 
38
33
  const header = (
39
34
  <>
40
35
  {identityIcon}
41
36
  <Shimmer active={running} className={cn("min-w-0 flex-1 truncate text-left text-[14px] font-medium", !running && "text-muted-foreground", expandable && !running && "transition-colors group-hover:text-foreground")}>{displayLabel}</Shimmer>
42
- <ToolUiTrailing running={running} failed={hasError} chevron={expandable} chevronClassName={cn("group-hover:opacity-100", open && "opacity-100 rotate-90")} />
37
+ <ToolUiTrailing failed={hasError} chevron={expandable} chevronClassName={cn("group-hover:opacity-100", open && "opacity-100 rotate-90")} />
43
38
  </>
44
39
  );
45
40
 
46
41
  return (
47
42
  <div className={cn("nonla-chat-tool mt-1", className)}>
48
43
  <div className="px-4 pb-0.5">
49
- <div className={cn("mb-1 overflow-hidden rounded-xl border", hasError ? "border-destructive/35" : "border-border")}>
44
+ <div className={cn("mb-1 overflow-hidden rounded-md", open && "bg-card")}>
50
45
  {expandable ? (
51
- <button type="button" onClick={() => setOpen((v) => !v)} className={cn("group flex w-full cursor-pointer items-center gap-2 px-2.5 py-1.5 outline-none transition-colors hover:bg-muted border-0 bg-transparent", open && "bg-muted")}>
46
+ <button type="button" onClick={() => setOpen((v) => !v)} className="group flex w-full cursor-pointer items-center gap-2 border-0 bg-transparent px-2.5 py-1 outline-none">
52
47
  {header}
53
48
  </button>
54
49
  ) : (
55
- <div className="flex w-full items-center gap-2 px-2.5 py-1.5">{header}</div>
50
+ <div className="flex w-full items-center gap-2 bg-transparent px-2.5 py-1">{header}</div>
56
51
  )}
57
52
 
58
53
  {open && expandable ? (
59
- <div className="border-t border-border font-mono text-[14px]">
60
- {hasInput ? <pre className="m-0 max-h-27.5 overflow-y-auto bg-transparent px-3 py-2 break-all whitespace-pre-wrap font-normal leading-[1.65] text-muted-foreground">{prettyJson(toolInput)}</pre> : null}
54
+ <div className="font-mono text-[13px]">
55
+ {hasInput ? <pre className="m-0 max-h-27.5 overflow-y-auto bg-well px-3 py-1.5 break-all whitespace-pre-wrap font-normal leading-[1.65] text-muted-foreground">{prettyJson(toolInput)}</pre> : null}
61
56
  {running ? (
62
- <div className="flex items-center gap-2 border-t border-border bg-muted/40 px-3 py-2 text-muted-foreground">
63
- <ChatSpinner />
57
+ <div className="bg-well-strong px-3 py-1.5 text-muted-foreground">
64
58
  <Shimmer className="italic">Running…</Shimmer>
65
59
  </div>
66
60
  ) : null}
67
61
  {!isPending ? (
68
- <pre className={cn("m-0 max-h-75 overflow-y-auto px-3 py-2 break-all whitespace-pre-wrap font-normal leading-[1.65] text-muted-foreground", hasInput && "border-t border-border", hasError ? "bg-destructive/6" : "bg-muted/40")}>
62
+ <pre className={cn("m-0 max-h-75 overflow-y-auto px-3 py-1.5 break-all whitespace-pre-wrap font-normal leading-[1.65] text-muted-foreground", hasError ? "bg-destructive/6" : "nonla-chat-tool-result-ok")}>
69
63
  {hasOutput ? prettyJson(toolOutput) : typeof toolError === "string" ? toolError : "Tool execution failed"}
70
64
  </pre>
71
65
  ) : null}
@@ -1,6 +1,6 @@
1
1
  import { FluentIcon } from "../../icon/FluentIcon";
2
- import { parseJsonObject } from "../common/utils";
3
2
  import { Shimmer } from "../../shimmer/Shimmer";
3
+ import { parseJsonObject } from "../common/utils";
4
4
  import { ToolUiBadge } from "./ToolUiBadge";
5
5
  import { ToolUiTrailing } from "./ToolUiTrailing";
6
6
  import { isToolRunning, type ToolUIProps } from "./types";
@@ -42,7 +42,7 @@ export function GetCurrentTimeToolUI({ msg, assistantLabel = "Assistant", assist
42
42
  <div className="flex items-center gap-2 py-1">
43
43
  <FluentIcon name="clock-24" size={13} className="shrink-0 text-muted-foreground" />
44
44
  <Shimmer active={running} className="truncate text-[14px] font-medium tabular-nums text-muted-foreground">{hasError ? "Failed to get time" : running ? "Getting time…" : label}</Shimmer>
45
- <ToolUiTrailing running={running} failed={hasError} />
45
+ <ToolUiTrailing failed={hasError} />
46
46
  </div>
47
47
  </div>
48
48
  </div>
@@ -60,16 +60,16 @@ export function ReadSkillToolUI({ msg, assistantLabel = "Assistant", assistantCo
60
60
  {verb}
61
61
  {targetLabel ? <span className="font-normal text-tertiary-foreground"> {targetLabel}</span> : null}
62
62
  </Shimmer>
63
- <ToolUiTrailing running={running} failed={failed} chevron chevronClassName="group-hover/readskill:opacity-100 group-open/readskill:opacity-100 group-open/readskill:rotate-90" />
63
+ <ToolUiTrailing failed={failed} chevron chevronClassName="group-hover/readskill:opacity-100 group-open/readskill:opacity-100 group-open/readskill:rotate-90" />
64
64
  </summary>
65
65
  {body ? (
66
- <pre className={cn("m-0 mt-1.5 mb-1 max-h-40 overflow-y-auto rounded-lg border px-3 py-2 font-mono text-[14px] font-normal leading-[1.65] break-all whitespace-pre-wrap", failed ? "border-destructive/35 bg-destructive/6 text-destructive" : "border-(--popper-border) bg-(--nonla-elevated) text-muted-foreground")}>{body}</pre>
66
+ <pre className={cn("m-0 mt-1.5 mb-1 max-h-40 overflow-y-auto rounded-lg border px-3 py-2 font-mono text-[13px] font-normal leading-[1.65] break-all whitespace-pre-wrap", failed ? "border-destructive/35 bg-destructive/6 text-destructive" : "border-(--popper-border) bg-(--nonla-elevated) text-muted-foreground")}>{body}</pre>
67
67
  ) : null}
68
68
  {!running && !reference && available.length > 0 ? (
69
69
  <div className="mt-0.5 mb-1 flex flex-wrap items-center gap-1">
70
70
  <span className="text-[10px] font-medium tracking-wide text-quaternary-foreground uppercase">Refs</span>
71
71
  {available.map((name) => (
72
- <span key={name} className="inline-flex max-w-full items-center rounded-md border border-border-subtle bg-muted/40 px-1.5 py-0.5 font-mono text-[14px] leading-[1.4] text-tertiary-foreground">
72
+ <span key={name} className="inline-flex max-w-full items-center rounded-md border border-border-subtle bg-muted/40 px-1.5 py-0.5 font-mono text-[13px] leading-[1.4] text-tertiary-foreground">
73
73
  {skillName ? `${skillName} / ${name}` : name}
74
74
  </span>
75
75
  ))}
@@ -1,6 +1,6 @@
1
+ import { CodeBlock } from "../../codeblock/CodeBlock";
1
2
  import { FluentIcon } from "../../icon/FluentIcon";
2
3
  import { cn } from "../../lib/cn";
3
- import { CodeBlock } from "../../codeblock/CodeBlock";
4
4
  import { Shimmer } from "../../shimmer/Shimmer";
5
5
  import { parseBgTaskRef } from "../common/bgTasks";
6
6
  import { parseJsonObject, prettyJson } from "../common/utils";
@@ -87,7 +87,7 @@ export function RunJsToolUI({ msg, assistantLabel = "Assistant", assistantColor,
87
87
  <ToolUiBadge show={showAvatar} label={assistantLabel} color={assistantColor} />
88
88
  <details className="group/runjs px-4 pb-2" style={{ overflowAnchor: "none" }}>
89
89
  <summary className="flex cursor-pointer list-none items-center gap-2 py-0.5 text-[14px] leading-5.5 select-none [&::-webkit-details-marker]:hidden">
90
- <FluentIcon name="code-24" size={13} className="shrink-0 text-muted-foreground" />
90
+ <FluentIcon name="code-block-24" size={13} className="shrink-0 text-muted-foreground" />
91
91
  <Shimmer active={running} className="min-w-0 truncate font-medium text-muted-foreground">{verb}</Shimmer>
92
92
  <ToolUiTrailing running={running} failed={failed} chevron chevronClassName="group-hover/runjs:opacity-100 group-open/runjs:opacity-100 group-open/runjs:rotate-90" />
93
93
  </summary>
@@ -1,4 +1,4 @@
1
- import { FluentIcon } from "../../icon/FluentIcon";
1
+ import { ChevronRight, CircleX } from "lucide-react";
2
2
  import { cn } from "../../lib/cn";
3
3
  import { ChatSpinner } from "../message-ui/ChatSpinner";
4
4
 
@@ -14,11 +14,7 @@ export function ToolUiTrailing({
14
14
  chevronClassName?: string;
15
15
  }) {
16
16
  if (running) return <ChatSpinner />;
17
- if (failed) return <FluentIcon name="error-circle-24" size={13} className="text-destructive" />;
17
+ if (failed) return <CircleX size={13} className="text-destructive" aria-hidden />;
18
18
  if (!chevron) return null;
19
- return (
20
- <svg className={cn("h-3 w-3 shrink-0 opacity-0 transition-[opacity,transform] duration-150", chevronClassName)} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
21
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
22
- </svg>
23
- );
19
+ return <ChevronRight size={12} className={cn("shrink-0 opacity-0 transition-[opacity,transform] duration-150", chevronClassName)} aria-hidden />;
24
20
  }
@@ -32,23 +32,32 @@ export function WebFetchToolUI({ msg, assistantLabel = "Assistant", assistantCol
32
32
  const running = isToolRunning(msg, generating);
33
33
  const url = output?.url || inputUrl(msg.toolInput) || "—";
34
34
  const body = failed ? (output?.error ?? (typeof msg.toolError === "string" ? msg.toolError : null) ?? "Tool execution failed") : output?.text?.trim() || "";
35
- const verb = running || failed ? "Fetch page" : "Fetched page";
35
+ const expandable = Boolean(body);
36
+ const verb = running ? "Fetching page" : failed ? "Fetch page" : "Fetched page";
37
+
38
+ const header = (
39
+ <>
40
+ <FluentIcon name="globe-24" size={13} className="shrink-0 text-muted-foreground" />
41
+ <Shimmer active={running} className="min-w-0 truncate font-medium text-muted-foreground">
42
+ {verb} <span className="font-normal text-tertiary-foreground">{url}</span>
43
+ </Shimmer>
44
+ <ToolUiTrailing failed={failed} chevron={expandable} chevronClassName="group-hover/webfetch:opacity-100 group-open/webfetch:opacity-100 group-open/webfetch:rotate-90" />
45
+ </>
46
+ );
36
47
 
37
48
  return (
38
49
  <div className="mt-1 animate-fadeIn">
39
50
  <ToolUiBadge show={showAvatar} label={assistantLabel} color={assistantColor} />
40
- <details className="group/webfetch px-4 pb-2" style={{ overflowAnchor: "none" }}>
41
- <summary className="flex cursor-pointer list-none items-center gap-2 py-0.5 text-[14px] leading-5.5 select-none [&::-webkit-details-marker]:hidden">
42
- <FluentIcon name="globe-24" size={13} className="shrink-0 text-muted-foreground" />
43
- <Shimmer active={running} className="min-w-0 truncate font-medium text-muted-foreground">
44
- {verb} <span className="font-normal text-tertiary-foreground">{url}</span>
45
- </Shimmer>
46
- <ToolUiTrailing running={running} failed={failed} chevron chevronClassName="group-hover/webfetch:opacity-100 group-open/webfetch:opacity-100 group-open/webfetch:rotate-90" />
47
- </summary>
48
- {body ? (
49
- <pre className={cn("m-0 mt-1.5 mb-1 max-h-40 overflow-y-auto rounded-lg border px-3 py-2 font-mono text-[14px] font-normal leading-[1.65] break-all whitespace-pre-wrap", failed ? "border-destructive/35 bg-destructive/6 text-destructive" : "border-(--popper-border) bg-(--nonla-elevated) text-muted-foreground")}>{body}</pre>
50
- ) : null}
51
- </details>
51
+ {expandable ? (
52
+ <details className="group/webfetch px-4 pb-2" style={{ overflowAnchor: "none" }}>
53
+ <summary className="flex cursor-pointer list-none items-center gap-2 py-0.5 text-[14px] leading-5.5 select-none [&::-webkit-details-marker]:hidden">{header}</summary>
54
+ <pre className={cn("m-0 mt-1.5 mb-1 max-h-40 overflow-y-auto rounded-lg border px-3 py-2 font-mono text-[13px] font-normal leading-[1.65] break-all whitespace-pre-wrap", failed ? "border-destructive/35 bg-destructive/6 text-destructive" : "border-(--popper-border) bg-(--nonla-elevated) text-muted-foreground")}>{body}</pre>
55
+ </details>
56
+ ) : (
57
+ <div className="px-4 pb-2">
58
+ <div className="flex items-center gap-2 py-0.5 text-[14px] leading-5.5">{header}</div>
59
+ </div>
60
+ )}
52
61
  </div>
53
62
  );
54
63
  }
@@ -1,5 +1,5 @@
1
1
  import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
2
- import { type ComponentPropsWithoutRef, type ReactNode, forwardRef } from "react";
2
+ import { type ComponentPropsWithoutRef, forwardRef, type ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
 
5
5
  export type CheckboxProps = Omit<ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>, "onCheckedChange" | "checked" | "onChange"> & {
@@ -1,6 +1,9 @@
1
- import { type ComponentProps, type ReactNode, createContext, useContext, useMemo } from "react";
1
+ import { FileText } from "lucide-react";
2
+ import { type ComponentProps, createContext, type ReactNode, useContext, useMemo } from "react";
2
3
  import { ButtonCopy } from "../button/ButtonCopy";
3
4
  import { cn } from "../lib/cn";
5
+ import type { ControlSize } from "../lib/sizes";
6
+ import { OverlayScroll } from "../scroll/OverlayScroll";
4
7
  import { highlightCode, languageLabel, wrapHljsLines } from "./highlight";
5
8
 
6
9
  type CodeBlockContextValue = {
@@ -22,16 +25,9 @@ export type CodeBlockProps = ComponentProps<"div"> & {
22
25
  wordWrap?: boolean;
23
26
  };
24
27
 
25
- function CodeFileIcon({ size = 14 }: { size?: number }) {
26
- return (
27
- <svg width={size} height={size} viewBox="0 0 256 256" aria-hidden>
28
- <path fill="none" stroke="currentColor" strokeWidth="25" strokeLinecap="round" d="M208 128l-80 80M192 40 40 192" />
29
- </svg>
30
- );
31
- }
32
-
33
- export type CodeBlockCopyButtonProps = ComponentProps<"button"> & {
28
+ export type CodeBlockCopyButtonProps = Omit<ComponentProps<"button">, "size"> & {
34
29
  content?: string;
30
+ size?: ControlSize;
35
31
  };
36
32
 
37
33
  export function CodeBlockCopyButton({ content, className, ...props }: CodeBlockCopyButtonProps) {
@@ -54,7 +50,7 @@ export function CodeBlock({ code, language, title, lineNumbers = false, wordWrap
54
50
  <div className="flex min-w-0 items-center gap-2">
55
51
  {label ? (
56
52
  <>
57
- <CodeFileIcon />
53
+ <FileText size={14} className="shrink-0" aria-hidden />
58
54
  <span className="truncate font-medium">{label}</span>
59
55
  </>
60
56
  ) : null}
@@ -62,14 +58,16 @@ export function CodeBlock({ code, language, title, lineNumbers = false, wordWrap
62
58
  <CodeBlockCopyButton />
63
59
  </div>
64
60
 
65
- <div className={cn("nonla-codeblock-body nonla-codeblock-well relative min-w-0 overflow-y-auto bg-card font-mono text-[13px] leading-5", children ? "max-h-96" : "mx-0.5 mb-0.5 max-h-96 rounded-lg")}>
66
- <div className={cn("w-full", !wordWrap && "overflow-x-auto")}>
67
- <pre className={cn("nonla-codeblock-pre m-0 whitespace-pre break-normal", lineNumbers && "nonla-codeblock-lines", wordWrap && "whitespace-pre-wrap wrap-break-word")}>
68
- {/* biome-ignore lint/security/noDangerouslySetInnerHtml: highlight.js emits escaped HTML */}
69
- <code className={cn("block px-3 py-2.5", !wordWrap && "w-max min-w-full")} dangerouslySetInnerHTML={{ __html: html }} />
70
- </pre>
71
- </div>
72
- </div>
61
+ <OverlayScroll
62
+ autoHeight
63
+ className={cn("nonla-codeblock-body nonla-codeblock-well min-w-0 bg-card font-mono text-[13px] leading-5 max-h-96", !children && "mx-0.5 mb-0.5 rounded-lg")}
64
+ innerClassName={wordWrap ? undefined : "overflow-x-auto"}
65
+ >
66
+ <pre className={cn("nonla-codeblock-pre m-0 whitespace-pre break-normal", lineNumbers && "nonla-codeblock-lines", wordWrap && "whitespace-pre-wrap wrap-break-word")}>
67
+ {/* biome-ignore lint/security/noDangerouslySetInnerHtml: highlight.js emits escaped HTML */}
68
+ <code className={cn("block px-3 py-2.5", !wordWrap && "w-max min-w-full")} dangerouslySetInnerHTML={{ __html: html }} />
69
+ </pre>
70
+ </OverlayScroll>
73
71
  {children}
74
72
  </div>
75
73
  </CodeBlockContext.Provider>
@@ -2,10 +2,10 @@ import * as PopoverPrimitive from "@radix-ui/react-popover";
2
2
  import {
3
3
  type CSSProperties,
4
4
  type FC,
5
+ isValidElement,
5
6
  type KeyboardEvent,
6
- type PointerEvent as ReactPointerEvent,
7
7
  type ReactNode,
8
- isValidElement,
8
+ type PointerEvent as ReactPointerEvent,
9
9
  useEffect,
10
10
  useRef,
11
11
  useState,
@@ -27,17 +27,17 @@ import {
27
27
  import { glassOverlayClass } from "../lib/surface";
28
28
  import {
29
29
  Color,
30
- DEFAULT_COLOR,
31
- clamp,
32
- hsbaToRgba,
33
- parseColor,
34
30
  type ColorFormat,
35
31
  type ColorType,
32
+ clamp,
33
+ DEFAULT_COLOR,
36
34
  type HsbaColor,
35
+ hsbaToRgba,
36
+ parseColor,
37
37
  } from "./color";
38
38
 
39
- export { Color, DEFAULT_COLOR, parseColor };
40
39
  export type { ColorFormat, ColorType, HsbaColor };
40
+ export { Color, DEFAULT_COLOR, parseColor };
41
41
 
42
42
  export type PresetColorType = {
43
43
  label: ReactNode;
@@ -4,19 +4,19 @@ import {
4
4
  type ButtonHTMLAttributes,
5
5
  type CSSProperties,
6
6
  type ForwardRefExoticComponent,
7
+ forwardRef,
7
8
  type MouseEvent,
8
9
  type ReactElement,
9
10
  type ReactNode,
10
11
  type RefAttributes,
11
- forwardRef,
12
12
  useEffect,
13
13
  useMemo,
14
14
  useState,
15
15
  } from "react";
16
16
  import type { DateRange } from "react-day-picker";
17
- import { Calendar } from "../calendar/Calendar";
18
- import { Button } from "../button/Button";
19
17
  import { usePopupContainer } from "../app/context";
18
+ import { Button } from "../button/Button";
19
+ import { Calendar } from "../calendar/Calendar";
20
20
  import { cn } from "../lib/cn";
21
21
  import { type ControlSize, controlFieldFocusBorder, controlFieldStyle, controlFieldSurface, controlFieldTransition, controlStatusClass, getSizeTokens, useControlSize } from "../lib/sizes";
22
22
  import { glassOverlayClass } from "../lib/surface";
@@ -66,27 +66,22 @@ export function DesktopBarDivider({ className }: { className?: string }) {
66
66
  return <span className={cn("mx-2 h-4 w-px bg-ink-line", className)} aria-hidden />;
67
67
  }
68
68
 
69
- export function DesktopHeader({
70
- logo,
71
- leading,
72
- trailing,
73
- profile,
74
- }: {
75
- logo?: ReactNode;
76
- leading?: ReactNode;
77
- trailing?: ReactNode;
78
- profile?: ReactNode;
79
- }) {
69
+ export type DesktopHeaderProps = {
70
+ left?: ReactNode;
71
+ right?: ReactNode;
72
+ className?: string;
73
+ };
74
+
75
+ export function DesktopHeader({ left, right, className }: DesktopHeaderProps) {
80
76
  return (
81
- <header className="nonla-header-layer fixed inset-x-0 top-0 flex h-desktop-bar items-center justify-between gap-3 border-0 bg-glass-bar px-3 backdrop-blur-lg">
82
- <div className="flex min-w-0 items-center">
83
- {logo}
84
- {leading}
85
- </div>
86
- <div className="flex min-w-0 shrink-0 items-center">
87
- {trailing}
88
- {profile}
89
- </div>
77
+ <header
78
+ className={cn(
79
+ "nonla-header-layer fixed inset-x-0 top-0 flex h-desktop-bar items-center justify-between gap-3 border-0 bg-glass-bar px-3 backdrop-blur-lg",
80
+ className,
81
+ )}
82
+ >
83
+ <div className="flex min-w-0 items-center">{left}</div>
84
+ <div className="flex min-w-0 shrink-0 items-center">{right}</div>
90
85
  </header>
91
86
  );
92
87
  }
@@ -1,13 +1,11 @@
1
- import { type ReactNode, forwardRef } from "react";
1
+ import { forwardRef, type ReactNode } from "react";
2
2
  import { FluentIcon } from "../icon/FluentIcon";
3
3
 
4
4
  const iconButtonClass =
5
- "group w-[96px] flex flex-col items-center gap-2 py-2 px-0 rounded-md border-none cursor-pointer select-none outline-none bg-transparent focus-visible:outline-2 focus-visible:outline-brand motion-safe:transition-transform motion-safe:hover:-translate-y-0.5 motion-safe:active:scale-[0.98]";
5
+ "group w-[96px] flex flex-col items-center gap-0.5 py-2 px-0 rounded-md border-none cursor-pointer select-none outline-none bg-transparent focus-visible:outline-2 focus-visible:outline-brand motion-safe:transition-transform motion-safe:hover:-translate-y-0.5 motion-safe:active:scale-[0.98]";
6
6
 
7
7
  const labelClass = "text-xs leading-[18px] font-semibold text-center line-clamp-2 w-full";
8
8
 
9
- const plateClass = "nonla-window-glass nonla-desktop-icon-plate w-11 h-11 flex items-center justify-center";
10
-
11
9
  export const DesktopIcon = forwardRef<
12
10
  HTMLButtonElement,
13
11
  {
@@ -21,11 +19,7 @@ export const DesktopIcon = forwardRef<
21
19
  >(function DesktopIcon({ label, icon, media, active = false, onCard = false, onClick }, ref) {
22
20
  return (
23
21
  <button ref={ref} type="button" title={label} aria-label={label} aria-current={active ? "true" : undefined} dir="ltr" onClick={onClick} className={iconButtonClass}>
24
- {media ?? (
25
- <span className={plateClass}>
26
- <FluentIcon name={icon ?? "sparkle-24"} size={32} />
27
- </span>
28
- )}
22
+ {media ?? <FluentIcon name={icon ?? "sparkle-24"} size={32} />}
29
23
  <span className={`${labelClass} ${onCard ? "text-foreground" : "text-white [text-shadow:0_1px_2px_rgba(0,0,0,0.85)]"}`}>{label}</span>
30
24
  </button>
31
25
  );
@@ -1,8 +1,8 @@
1
- import { type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1
+ import { createContext, type MouseEvent as ReactMouseEvent, type ReactNode, type PointerEvent as ReactPointerEvent, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { cn } from "../lib/cn";
4
- import { OverlayScroll, type OverlayScrollVisibility } from "../scroll/OverlayScroll";
5
4
  import { glassSurfaceClass } from "../lib/surface";
5
+ import { OverlayScroll, type OverlayScrollVisibility } from "../scroll/OverlayScroll";
6
6
 
7
7
  type Phase = "open" | "leaving";
8
8
  type Size = { w: number; h: number };
@@ -37,6 +37,10 @@ function isEditableTarget(target: EventTarget | null) {
37
37
  return Boolean(target.closest(".monaco-editor"));
38
38
  }
39
39
 
40
+ function hasOpenDialog() {
41
+ return Boolean(document.querySelector('[role="dialog"][data-state="open"], .nonla-modal-content'));
42
+ }
43
+
40
44
  function originFromActiveIcon(overlay: HTMLElement, frame: HTMLElement) {
41
45
  const source = document.querySelector('[aria-current="true"]');
42
46
  if (!(source instanceof HTMLElement)) return "50% 50%";
@@ -190,33 +194,33 @@ function TrafficLight({
190
194
  }
191
195
 
192
196
  type WindowHeaderSlot = {
193
- slot: HTMLElement | null;
194
- setCustom: (on: boolean) => void;
197
+ left: HTMLElement | null;
198
+ right: HTMLElement | null;
195
199
  };
196
200
 
197
201
  const WindowHeaderSlotContext = createContext<WindowHeaderSlot | null>(null);
198
202
 
199
- export function WindowHeader({ children }: { children: ReactNode }) {
203
+ export type WindowHeaderProps = {
204
+ left?: ReactNode;
205
+ right?: ReactNode;
206
+ };
207
+
208
+ /** Injects into the window title bar from a child. Prefer `DesktopWindow` `left` / `right` when the parent can pass them. */
209
+ export function WindowHeader({ left, right }: WindowHeaderProps) {
200
210
  const ctx = useContext(WindowHeaderSlotContext);
201
- useLayoutEffect(() => {
202
- if (!ctx) return;
203
- ctx.setCustom(true);
204
- return () => ctx.setCustom(false);
205
- }, [ctx]);
206
- if (!ctx?.slot) return null;
207
- return createPortal(children, ctx.slot);
211
+ if (!ctx) return null;
212
+ return (
213
+ <>
214
+ {ctx.left && left != null ? createPortal(left, ctx.left) : null}
215
+ {ctx.right && right != null ? createPortal(right, ctx.right) : null}
216
+ </>
217
+ );
208
218
  }
209
219
 
210
- export function DesktopWindow({
211
- title,
212
- expanded,
213
- onClose,
214
- onToggleExpand,
215
- scroll = true,
216
- scrollbar = "hover",
217
- children,
218
- }: {
220
+ export type DesktopWindowProps = {
219
221
  title?: ReactNode;
222
+ left?: ReactNode;
223
+ right?: ReactNode;
220
224
  expanded: boolean;
221
225
  onClose: () => void;
222
226
  onToggleExpand: () => void;
@@ -224,7 +228,19 @@ export function DesktopWindow({
224
228
  scroll?: boolean;
225
229
  scrollbar?: OverlayScrollVisibility;
226
230
  children: ReactNode;
227
- }) {
231
+ };
232
+
233
+ export function DesktopWindow({
234
+ title,
235
+ left,
236
+ right,
237
+ expanded,
238
+ onClose,
239
+ onToggleExpand,
240
+ scroll = true,
241
+ scrollbar = "hover",
242
+ children,
243
+ }: DesktopWindowProps) {
228
244
  const overlayRef = useRef<HTMLDivElement>(null);
229
245
  const frameRef = useRef<HTMLElement>(null);
230
246
  const closedRef = useRef(false);
@@ -253,9 +269,9 @@ export function DesktopWindow({
253
269
  w: window.innerWidth,
254
270
  h: Math.max(0, window.innerHeight - 42),
255
271
  }));
256
- const [headerSlot, setHeaderSlot] = useState<HTMLDivElement | null>(null);
257
- const [customHeader, setCustomHeader] = useState(false);
258
- const headerChrome = useMemo(() => ({ slot: headerSlot, setCustom: setCustomHeader }), [headerSlot]);
272
+ const [leftSlot, setLeftSlot] = useState<HTMLDivElement | null>(null);
273
+ const [rightSlot, setRightSlot] = useState<HTMLDivElement | null>(null);
274
+ const headerChrome = useMemo(() => ({ left: leftSlot, right: rightSlot }), [leftSlot, rightSlot]);
259
275
  const requestCloseRef = useRef<() => void>(() => {});
260
276
  const viewRef = useRef(view);
261
277
  const offsetRef = useRef(offset);
@@ -325,7 +341,7 @@ export function DesktopWindow({
325
341
 
326
342
  useEffect(() => {
327
343
  const onKey = (e: KeyboardEvent) => {
328
- if (e.key !== "Escape" || isEditableTarget(e.target)) return;
344
+ if (e.key !== "Escape" || e.defaultPrevented || isEditableTarget(e.target) || hasOpenDialog()) return;
329
345
  e.preventDefault();
330
346
  requestCloseRef.current();
331
347
  };
@@ -475,10 +491,8 @@ export function DesktopWindow({
475
491
  className={cn("absolute flex flex-col rounded-xl pointer-events-auto transform-gpu", glassSurfaceClass, "nonla-window-glass", leaving && "pointer-events-none")}
476
492
  >
477
493
  <div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl">
478
- {/* biome-ignore lint/a11y/noStaticElementInteractions: title bar is a pointer drag surface */}
479
494
  <header
480
495
  onPointerDown={startDrag}
481
- onDoubleClick={onTitleBarDoubleClick}
482
496
  className="flex h-8 shrink-0 cursor-default items-center gap-4 border-0 border-b border-solid border-ink-line px-3 select-none touch-none"
483
497
  >
484
498
  <div className="group/traffic flex shrink-0 items-center gap-2">
@@ -490,8 +504,18 @@ export function DesktopWindow({
490
504
  </TrafficLight>
491
505
  </div>
492
506
  <div className="flex min-w-0 flex-1 items-center">
493
- {customHeader ? null : typeof title === "string" ? <span className="min-w-0 truncate text-xs font-semibold leading-none text-foreground/90">{title}</span> : title}
494
- <div ref={setHeaderSlot} className="contents" />
507
+ <div className="flex min-w-0 items-center">
508
+ {left}
509
+ <div ref={setLeftSlot} className="contents" />
510
+ </div>
511
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: middle strip double-clicks to expand */}
512
+ <div onDoubleClick={onTitleBarDoubleClick} className="flex min-w-8 flex-1 items-center self-stretch px-2">
513
+ {typeof title === "string" ? <span className="min-w-0 truncate text-xs font-semibold leading-none text-foreground/90">{title}</span> : title}
514
+ </div>
515
+ <div className="flex shrink-0 items-center">
516
+ {right}
517
+ <div ref={setRightSlot} className="contents" />
518
+ </div>
495
519
  </div>
496
520
  </header>
497
521
 
@@ -6,7 +6,7 @@ export function MeadowDesktop({ children, src }: { children?: ReactNode; src?: s
6
6
  <div className="absolute inset-0 bg-meadow">
7
7
  <MeadowWallpaper src={src} />
8
8
  <div className="nonla-desktop-layer absolute inset-x-0 bottom-0 top-desktop-bar">
9
- <nav aria-label="Desktop" className="absolute inset-0 overflow-auto pl-2 pr-3 pt-2 pb-3">
9
+ <nav aria-label="Desktop" className="absolute inset-0 overflow-auto pl-0 pr-3 pt-3 pb-3">
10
10
  <div className="flex h-full flex-col flex-wrap content-start gap-x-2 gap-y-3">{children}</div>
11
11
  </nav>
12
12
  </div>
package/src/form/Form.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef } from "react";
2
- import { FormProvider, type FieldValues, type UseFormReturn, useWatch } from "react-hook-form";
2
+ import { type FieldValues, FormProvider, type UseFormReturn, useWatch } from "react-hook-form";
3
3
  import { cn } from "../lib/cn";
4
4
  import { FormExtraContext, type FormFetcher } from "./common/context";
5
5
  import { fieldNameOf } from "./common/rules";
@@ -1,5 +1,5 @@
1
- import { useState, type ComponentType } from "react";
2
- import { Controller, type Control, type FieldValues } from "react-hook-form";
1
+ import { type ComponentType, useState } from "react";
2
+ import { type Control, Controller, type FieldValues } from "react-hook-form";
3
3
  import { cn } from "../lib/cn";
4
4
  import { EFormItemType } from "./common/enum";
5
5
  import { fieldNameOf, hydrateRules, isRuleRequired } from "./common/rules";