devnonla-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/package.json +66 -0
  4. package/src/alert/Alert.tsx +109 -0
  5. package/src/app/App.tsx +36 -0
  6. package/src/button/Button.tsx +278 -0
  7. package/src/button/ButtonCopy.tsx +60 -0
  8. package/src/calendar/Calendar.tsx +133 -0
  9. package/src/chat/AgentPanel.tsx +203 -0
  10. package/src/chat/common/bgTasks.ts +41 -0
  11. package/src/chat/common/sse.ts +156 -0
  12. package/src/chat/common/types.ts +43 -0
  13. package/src/chat/common/useAgentStream.ts +351 -0
  14. package/src/chat/common/utils.ts +73 -0
  15. package/src/chat/index.ts +56 -0
  16. package/src/chat/message-ui/ChatAgentMessage.tsx +29 -0
  17. package/src/chat/message-ui/ChatError.tsx +24 -0
  18. package/src/chat/message-ui/ChatInput.tsx +162 -0
  19. package/src/chat/message-ui/ChatMarkdown.tsx +128 -0
  20. package/src/chat/message-ui/ChatMarkdownTable.tsx +45 -0
  21. package/src/chat/message-ui/ChatSpinner.tsx +5 -0
  22. package/src/chat/message-ui/ChatThinking.tsx +61 -0
  23. package/src/chat/message-ui/ChatUserMessage.tsx +72 -0
  24. package/src/chat/message-ui/ChatWelcome.tsx +70 -0
  25. package/src/chat/message-ui/MermaidBlock.tsx +266 -0
  26. package/src/chat/message-ui/mermaidFence.ts +55 -0
  27. package/src/chat/message-ui/sanitizeMermaid.ts +135 -0
  28. package/src/chat/tool-ui/BackgroundTaskToolUI.tsx +36 -0
  29. package/src/chat/tool-ui/BackgroundTasksBar.tsx +152 -0
  30. package/src/chat/tool-ui/CallAgentToolUI.tsx +168 -0
  31. package/src/chat/tool-ui/ChatToolCall.tsx +77 -0
  32. package/src/chat/tool-ui/GetCurrentTimeToolUI.tsx +49 -0
  33. package/src/chat/tool-ui/ReadSkillToolUI.tsx +80 -0
  34. package/src/chat/tool-ui/RunJsToolUI.tsx +106 -0
  35. package/src/chat/tool-ui/ToolUiBadge.tsx +10 -0
  36. package/src/chat/tool-ui/ToolUiTrailing.tsx +24 -0
  37. package/src/chat/tool-ui/WebFetchToolUI.tsx +53 -0
  38. package/src/chat/tool-ui/registry.ts +29 -0
  39. package/src/chat/tool-ui/types.ts +22 -0
  40. package/src/checkbox/Checkbox.tsx +50 -0
  41. package/src/codeblock/CodeBlock.tsx +76 -0
  42. package/src/codeblock/highlight.ts +159 -0
  43. package/src/colorpicker/ColorPicker.tsx +45 -0
  44. package/src/datepicker/DatePicker.tsx +481 -0
  45. package/src/desktop/DesktopHeader.tsx +92 -0
  46. package/src/desktop/DesktopIcon.tsx +34 -0
  47. package/src/desktop/DesktopStage.tsx +6 -0
  48. package/src/desktop/DesktopWindow.tsx +504 -0
  49. package/src/desktop/MeadowDesktop.tsx +15 -0
  50. package/src/desktop/MeadowShell.tsx +12 -0
  51. package/src/desktop/MeadowWallpaper.tsx +5 -0
  52. package/src/desktop/assets/desktop-meadow.jpg +0 -0
  53. package/src/desktop/assets.d.ts +9 -0
  54. package/src/desktop/surface.ts +1 -0
  55. package/src/drawer/Drawer.tsx +125 -0
  56. package/src/dropdown/ContextMenu.tsx +97 -0
  57. package/src/dropdown/Dropdown.tsx +139 -0
  58. package/src/dropdown/menuClasses.ts +9 -0
  59. package/src/empty/Empty.tsx +47 -0
  60. package/src/form/Form.tsx +95 -0
  61. package/src/form/FormItem.tsx +145 -0
  62. package/src/form/common/context.ts +13 -0
  63. package/src/form/common/enum.ts +20 -0
  64. package/src/form/common/rules.ts +68 -0
  65. package/src/form/common/types.ts +169 -0
  66. package/src/form/index.ts +35 -0
  67. package/src/form/partials/FormItemHelps.tsx +31 -0
  68. package/src/form/variants/FieldCheckbox.tsx +20 -0
  69. package/src/form/variants/FieldColor.tsx +63 -0
  70. package/src/form/variants/FieldDateTime.tsx +25 -0
  71. package/src/form/variants/FieldHidden.tsx +9 -0
  72. package/src/form/variants/FieldInput.tsx +24 -0
  73. package/src/form/variants/FieldNumber.tsx +28 -0
  74. package/src/form/variants/FieldObject.tsx +29 -0
  75. package/src/form/variants/FieldRadio.tsx +40 -0
  76. package/src/form/variants/FieldRepeater.tsx +62 -0
  77. package/src/form/variants/FieldRepeaterItem.tsx +64 -0
  78. package/src/form/variants/FieldSelect.tsx +27 -0
  79. package/src/form/variants/FieldSelectMultiple.tsx +32 -0
  80. package/src/form/variants/FieldSelectRemote.tsx +66 -0
  81. package/src/form/variants/FieldSwitch.tsx +17 -0
  82. package/src/form/variants/FieldTextarea.tsx +25 -0
  83. package/src/form/variants/FieldTime.tsx +25 -0
  84. package/src/form-layout/FormLayout.tsx +74 -0
  85. package/src/icon/FluentIcon.tsx +28 -0
  86. package/src/icon/fluent.ts +75 -0
  87. package/src/index.ts +214 -0
  88. package/src/input/Input.tsx +371 -0
  89. package/src/input/SearchInput.tsx +73 -0
  90. package/src/lib/cn.ts +6 -0
  91. package/src/lib/placement.ts +44 -0
  92. package/src/lib/sizes.ts +117 -0
  93. package/src/lib/surface.ts +8 -0
  94. package/src/menu/Menu.tsx +98 -0
  95. package/src/message/message.tsx +214 -0
  96. package/src/modal/Modal.tsx +277 -0
  97. package/src/pagination/Pagination.tsx +103 -0
  98. package/src/popconfirm/Popconfirm.tsx +134 -0
  99. package/src/popover/Popover.tsx +116 -0
  100. package/src/scroll/OverlayScroll.tsx +141 -0
  101. package/src/segmented/Segmented.tsx +67 -0
  102. package/src/select/Select.tsx +319 -0
  103. package/src/skeleton/Skeleton.tsx +47 -0
  104. package/src/spin/Spin.tsx +160 -0
  105. package/src/styles.css +897 -0
  106. package/src/switch/Switch.tsx +58 -0
  107. package/src/table/Table.tsx +387 -0
  108. package/src/tag/Tag.tsx +109 -0
  109. package/src/theme.ts +131 -0
  110. package/src/timepicker/TimePicker.tsx +751 -0
  111. package/src/tooltip/Tooltip.tsx +59 -0
@@ -0,0 +1,134 @@
1
+ import { type CSSProperties, type ReactNode, useState } from "react";
2
+ import { Button } from "../button/Button";
3
+ import { cn } from "../lib/cn";
4
+ import { type PopperPlacement } from "../lib/placement";
5
+ import { Popover } from "../popover/Popover";
6
+
7
+ export type PopconfirmProps = {
8
+ title?: ReactNode;
9
+ description?: ReactNode;
10
+ children: ReactNode;
11
+ onConfirm?: () => void | Promise<void>;
12
+ onCancel?: () => void;
13
+ okText?: ReactNode;
14
+ cancelText?: ReactNode;
15
+ okType?: "primary" | "default" | "dashed" | "link" | "text" | "danger";
16
+ okButtonProps?: { danger?: boolean; type?: string; color?: string; variant?: string };
17
+ cancelButtonProps?: Record<string, unknown>;
18
+ /** Customize / hide the leading icon. Pass `null` to hide. */
19
+ icon?: ReactNode | null;
20
+ showCancel?: boolean;
21
+ disabled?: boolean;
22
+ open?: boolean;
23
+ onOpenChange?: (open: boolean) => void;
24
+ placement?: PopperPlacement;
25
+ className?: string;
26
+ overlayClassName?: string;
27
+ getPopupContainer?: () => HTMLElement;
28
+ styles?: { root?: CSSProperties };
29
+ };
30
+
31
+ function WarningIcon() {
32
+ return (
33
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden className="mt-0.5 shrink-0">
34
+ <circle cx="8" cy="8" r="8" fill="var(--warn)" />
35
+ <path d="M8 4.4V8.6M8 10.7V11.4" stroke="var(--nonla-bg)" strokeWidth="1.7" strokeLinecap="round" />
36
+ </svg>
37
+ );
38
+ }
39
+
40
+ export function Popconfirm({
41
+ title,
42
+ description,
43
+ children,
44
+ onConfirm,
45
+ onCancel,
46
+ okText = "OK",
47
+ cancelText = "Cancel",
48
+ okType = "primary",
49
+ okButtonProps,
50
+ cancelButtonProps,
51
+ icon,
52
+ showCancel = true,
53
+ disabled,
54
+ open: openProp,
55
+ onOpenChange,
56
+ placement = "top",
57
+ className,
58
+ overlayClassName,
59
+ getPopupContainer,
60
+ styles,
61
+ }: PopconfirmProps) {
62
+ const [innerOpen, setInnerOpen] = useState(false);
63
+ const [loading, setLoading] = useState(false);
64
+ const open = openProp ?? innerOpen;
65
+ const setOpen = (v: boolean) => {
66
+ setInnerOpen(v);
67
+ onOpenChange?.(v);
68
+ };
69
+
70
+ if (disabled) return <>{children}</>;
71
+
72
+ const showIcon = icon !== null;
73
+ const resolvedIcon = icon === undefined ? <WarningIcon /> : icon;
74
+ const okDanger = okType === "danger" || okButtonProps?.danger;
75
+ const okBtnType = okButtonProps?.type ?? (okType === "danger" ? "primary" : okType);
76
+
77
+ return (
78
+ <Popover
79
+ open={open}
80
+ onOpenChange={setOpen}
81
+ placement={placement}
82
+ arrow
83
+ overlayClassName={overlayClassName}
84
+ contentClassName={cn("p-3!", className)}
85
+ getPopupContainer={getPopupContainer}
86
+ style={styles?.root}
87
+ content={
88
+ <div className="min-w-48 max-w-72">
89
+ <div className="flex gap-2">
90
+ {showIcon ? <span className="nonla-popconfirm-icon">{resolvedIcon}</span> : null}
91
+ <div className="min-w-0 flex-1">
92
+ {title != null && title !== false ? <div className="text-sm font-medium leading-snug text-foreground">{title}</div> : null}
93
+ {description != null && description !== false ? <div className={cn("text-[13px] leading-snug text-muted-foreground", title != null && title !== false && "mt-1")}>{description}</div> : null}
94
+ </div>
95
+ </div>
96
+ <div className="mt-3 flex justify-end gap-2">
97
+ {showCancel ? (
98
+ <Button
99
+ size="small"
100
+ type="default"
101
+ {...(cancelButtonProps as object)}
102
+ onClick={() => {
103
+ setOpen(false);
104
+ onCancel?.();
105
+ }}
106
+ >
107
+ {cancelText}
108
+ </Button>
109
+ ) : null}
110
+ <Button
111
+ size="small"
112
+ type={okBtnType as "primary" | "default" | "dashed" | "link" | "text"}
113
+ danger={okDanger}
114
+ loading={loading}
115
+ onClick={async () => {
116
+ try {
117
+ setLoading(true);
118
+ await onConfirm?.();
119
+ setOpen(false);
120
+ } finally {
121
+ setLoading(false);
122
+ }
123
+ }}
124
+ >
125
+ {okText}
126
+ </Button>
127
+ </div>
128
+ </div>
129
+ }
130
+ >
131
+ {children}
132
+ </Popover>
133
+ );
134
+ }
@@ -0,0 +1,116 @@
1
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
2
+ import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react";
3
+ import { useAppConfig } from "../app/App";
4
+ import { cn } from "../lib/cn";
5
+ import { type PopperPlacement, placementToRadix } from "../lib/placement";
6
+ import { glassOverlayClass } from "../lib/surface";
7
+
8
+ export type PopoverProps = {
9
+ content?: ReactNode;
10
+ title?: ReactNode;
11
+ children: ReactNode;
12
+ open?: boolean;
13
+ defaultOpen?: boolean;
14
+ onOpenChange?: (open: boolean) => void;
15
+ trigger?: "click" | "hover";
16
+ placement?: PopperPlacement;
17
+ mouseEnterDelay?: number;
18
+ mouseLeaveDelay?: number;
19
+ getPopupContainer?: () => HTMLElement;
20
+ /** Show arrow pointing at the trigger (antd default for Popconfirm). */
21
+ arrow?: boolean | { pointAtCenter?: boolean };
22
+ className?: string;
23
+ overlayClassName?: string;
24
+ /** Extra class on the content panel (padding / width). */
25
+ contentClassName?: string;
26
+ style?: CSSProperties;
27
+ /** antd `styles` — `root`/`body` map to content panel; `container` accepted as alias. */
28
+ styles?: {
29
+ root?: CSSProperties;
30
+ body?: CSSProperties;
31
+ content?: CSSProperties;
32
+ container?: CSSProperties;
33
+ };
34
+ };
35
+
36
+ export function Popover({
37
+ content,
38
+ title,
39
+ children,
40
+ open: openProp,
41
+ defaultOpen,
42
+ onOpenChange,
43
+ trigger = "click",
44
+ placement = "bottom",
45
+ mouseEnterDelay = 0.1,
46
+ mouseLeaveDelay = 0.1,
47
+ getPopupContainer,
48
+ arrow = false,
49
+ className,
50
+ overlayClassName,
51
+ contentClassName,
52
+ style,
53
+ styles,
54
+ }: PopoverProps) {
55
+ const showArrow = Boolean(arrow);
56
+ const panelStyle = { ...style, ...styles?.root, ...styles?.content, ...styles?.container, ...styles?.body };
57
+ const app = useAppConfig();
58
+ const { side, align } = placementToRadix(placement);
59
+ const hover = trigger === "hover";
60
+ const [innerOpen, setInnerOpen] = useState(defaultOpen ?? false);
61
+ const open = openProp ?? innerOpen;
62
+ const enterTimer = useRef<number>(0);
63
+ const leaveTimer = useRef<number>(0);
64
+
65
+ const setOpen = (v: boolean) => {
66
+ if (openProp === undefined) setInnerOpen(v);
67
+ onOpenChange?.(v);
68
+ };
69
+
70
+ useEffect(() => () => {
71
+ window.clearTimeout(enterTimer.current);
72
+ window.clearTimeout(leaveTimer.current);
73
+ }, []);
74
+
75
+ const onEnter = () => {
76
+ if (!hover) return;
77
+ window.clearTimeout(leaveTimer.current);
78
+ enterTimer.current = window.setTimeout(() => setOpen(true), mouseEnterDelay * 1000);
79
+ };
80
+ const onLeave = () => {
81
+ if (!hover) return;
82
+ window.clearTimeout(enterTimer.current);
83
+ leaveTimer.current = window.setTimeout(() => setOpen(false), mouseLeaveDelay * 1000);
84
+ };
85
+
86
+ const container = getPopupContainer ?? app.getPopupContainer;
87
+
88
+ return (
89
+ <PopoverPrimitive.Root
90
+ open={open}
91
+ onOpenChange={(v) => {
92
+ if (hover && v) return;
93
+ setOpen(v);
94
+ }}
95
+ >
96
+ <PopoverPrimitive.Trigger asChild onMouseEnter={onEnter} onMouseLeave={onLeave}>
97
+ {children}
98
+ </PopoverPrimitive.Trigger>
99
+ <PopoverPrimitive.Portal container={container?.()}>
100
+ <PopoverPrimitive.Content
101
+ side={side}
102
+ align={align}
103
+ sideOffset={showArrow ? 8 : 6}
104
+ onMouseEnter={onEnter}
105
+ onMouseLeave={onLeave}
106
+ className={cn("w-max max-w-sm p-3", glassOverlayClass, contentClassName, className, overlayClassName)}
107
+ style={panelStyle}
108
+ >
109
+ {title ? <div className="mb-2 text-sm font-medium">{title}</div> : null}
110
+ {content}
111
+ {showArrow ? <PopoverPrimitive.Arrow width={12} height={6} className="fill-glass drop-shadow-[0_1px_0_var(--glass-border)]" /> : null}
112
+ </PopoverPrimitive.Content>
113
+ </PopoverPrimitive.Portal>
114
+ </PopoverPrimitive.Root>
115
+ );
116
+ }
@@ -0,0 +1,141 @@
1
+ import { type PointerEvent as ReactPointerEvent, type ReactNode, type Ref, type UIEventHandler, useCallback, useLayoutEffect, useRef, useState } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ export type OverlayScrollVisibility = "hover" | "always";
5
+
6
+ export type OverlayScrollProps = {
7
+ /** `hover` shows the thumb on hover / while scrolling. `always` keeps it visible. */
8
+ visibility?: OverlayScrollVisibility;
9
+ className?: string;
10
+ innerClassName?: string;
11
+ children?: ReactNode;
12
+ onScroll?: UIEventHandler<HTMLDivElement>;
13
+ scrollRef?: Ref<HTMLDivElement>;
14
+ };
15
+
16
+ type Thumb = { top: number; height: number; shown: boolean };
17
+
18
+ function assignRef(ref: Ref<HTMLDivElement> | undefined, node: HTMLDivElement | null) {
19
+ if (!ref) return;
20
+ if (typeof ref === "function") ref(node);
21
+ else ref.current = node;
22
+ }
23
+
24
+ export function OverlayScroll({ visibility = "hover", className, innerClassName, children, onScroll, scrollRef }: OverlayScrollProps) {
25
+ const elRef = useRef<HTMLDivElement | null>(null);
26
+ const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
27
+ const thumbRef = useRef<Thumb>({ top: 0, height: 0, shown: false });
28
+ const [scrolling, setScrolling] = useState(false);
29
+ const [dragging, setDragging] = useState(false);
30
+ const [thumb, setThumb] = useState<Thumb>({ top: 0, height: 0, shown: false });
31
+ thumbRef.current = thumb;
32
+
33
+ const updateThumb = useCallback(() => {
34
+ const el = elRef.current;
35
+ if (!el) return;
36
+ const { scrollTop, scrollHeight, clientHeight } = el;
37
+ const overflow = scrollHeight - clientHeight;
38
+ if (overflow <= 1) {
39
+ setThumb((prev) => (prev.shown ? { top: 0, height: 0, shown: false } : prev));
40
+ return;
41
+ }
42
+ const height = Math.max(32, (clientHeight / scrollHeight) * clientHeight);
43
+ const top = (scrollTop / overflow) * (clientHeight - height);
44
+ setThumb({ top, height, shown: true });
45
+ }, []);
46
+
47
+ const setNode = useCallback(
48
+ (node: HTMLDivElement | null) => {
49
+ elRef.current = node;
50
+ assignRef(scrollRef, node);
51
+ if (node) requestAnimationFrame(updateThumb);
52
+ },
53
+ [scrollRef, updateThumb],
54
+ );
55
+
56
+ useLayoutEffect(() => {
57
+ const el = elRef.current;
58
+ if (!el) return;
59
+ updateThumb();
60
+ const ro = new ResizeObserver(updateThumb);
61
+ ro.observe(el);
62
+ const child = el.firstElementChild;
63
+ if (child) ro.observe(child);
64
+ return () => ro.disconnect();
65
+ }, [updateThumb]);
66
+
67
+ useLayoutEffect(() => {
68
+ return () => {
69
+ if (hideTimer.current) clearTimeout(hideTimer.current);
70
+ };
71
+ }, []);
72
+
73
+ const handleScroll: UIEventHandler<HTMLDivElement> = (e) => {
74
+ updateThumb();
75
+ if (visibility === "hover" && !dragging) {
76
+ setScrolling(true);
77
+ if (hideTimer.current) clearTimeout(hideTimer.current);
78
+ hideTimer.current = setTimeout(() => setScrolling(false), 700);
79
+ }
80
+ onScroll?.(e);
81
+ };
82
+
83
+ const onThumbPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
84
+ const el = elRef.current;
85
+ const current = thumbRef.current;
86
+ if (!el || !current.shown || e.button !== 0) return;
87
+ e.preventDefault();
88
+ e.stopPropagation();
89
+
90
+ const overflow = el.scrollHeight - el.clientHeight;
91
+ const track = el.clientHeight - current.height;
92
+ if (overflow <= 0 || track <= 0) return;
93
+
94
+ const startY = e.clientY;
95
+ const startTop = current.top;
96
+ e.currentTarget.setPointerCapture(e.pointerId);
97
+ setDragging(true);
98
+ setScrolling(true);
99
+
100
+ const onMove = (ev: PointerEvent) => {
101
+ if (ev.pointerId !== e.pointerId) return;
102
+ const nextTop = Math.min(track, Math.max(0, startTop + (ev.clientY - startY)));
103
+ el.scrollTop = (nextTop / track) * overflow;
104
+ };
105
+ const onUp = (ev: PointerEvent) => {
106
+ if (ev.pointerId !== e.pointerId) return;
107
+ window.removeEventListener("pointermove", onMove, true);
108
+ window.removeEventListener("pointerup", onUp, true);
109
+ window.removeEventListener("pointercancel", onUp, true);
110
+ setDragging(false);
111
+ if (hideTimer.current) clearTimeout(hideTimer.current);
112
+ hideTimer.current = setTimeout(() => setScrolling(false), 700);
113
+ };
114
+ window.addEventListener("pointermove", onMove, true);
115
+ window.addEventListener("pointerup", onUp, true);
116
+ window.addEventListener("pointercancel", onUp, true);
117
+ };
118
+
119
+ const thumbVisible = visibility === "always" || dragging || scrolling;
120
+
121
+ return (
122
+ <div className={cn("group/scroll relative min-h-0 min-w-0 w-full overflow-hidden", className)} data-scrolling={thumbVisible ? "true" : undefined}>
123
+ <div ref={setNode} onScroll={handleScroll} className={cn("nonla-scroll-hidden absolute inset-0 overflow-y-auto overflow-x-hidden [overflow-anchor:none]", innerClassName)}>
124
+ {children}
125
+ </div>
126
+ {thumb.shown ? (
127
+ <div
128
+ aria-hidden
129
+ onPointerDown={onThumbPointerDown}
130
+ className={cn(
131
+ "nonla-overlay-thumb absolute z-20 rounded-full transition-opacity duration-150",
132
+ visibility === "always" || dragging
133
+ ? "pointer-events-auto opacity-100"
134
+ : "pointer-events-none opacity-0 group-hover/scroll:pointer-events-auto group-hover/scroll:opacity-100 group-data-[scrolling]/scroll:pointer-events-auto group-data-[scrolling]/scroll:opacity-100",
135
+ )}
136
+ style={{ right: 2, width: "var(--nonla-scrollbar-size)", height: thumb.height, transform: `translateY(${thumb.top}px)` }}
137
+ />
138
+ ) : null}
139
+ </div>
140
+ );
141
+ }
@@ -0,0 +1,67 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+ import { type ControlSize, controlHeightVar, controlRadiusVar, getSizeTokens, normalizeSize } from "../lib/sizes";
4
+
5
+ export type SegmentedOption<V extends string | number = string | number> = V | { label: ReactNode; value: V; disabled?: boolean; icon?: ReactNode };
6
+
7
+ export type SegmentedProps<V extends string | number = string | number> = {
8
+ options: SegmentedOption<V>[];
9
+ value?: V;
10
+ defaultValue?: V;
11
+ onChange?: (value: V) => void;
12
+ size?: ControlSize;
13
+ block?: boolean;
14
+ disabled?: boolean;
15
+ className?: string;
16
+ };
17
+
18
+ function norm<V extends string | number>(opt: SegmentedOption<V>): { label: ReactNode; value: V; disabled?: boolean; icon?: ReactNode } {
19
+ if (typeof opt === "string" || typeof opt === "number") return { label: String(opt), value: opt };
20
+ return opt;
21
+ }
22
+
23
+ export function Segmented<V extends string | number = string | number>({ options, value, defaultValue, onChange, size, block, disabled, className }: SegmentedProps<V>) {
24
+ const items = options.map((o) => norm(o));
25
+ const current = value ?? defaultValue ?? items[0]?.value;
26
+ const tok = getSizeTokens(size);
27
+ const itemPadX = Math.max(tok.paddingInline - 4, 6);
28
+ const radius = controlRadiusVar(size);
29
+
30
+ return (
31
+ <div
32
+ className={cn("inline-flex w-fit max-w-full shrink-0 items-center gap-0.5 rounded-lg bg-brand-50 p-0.5", block && "flex w-full", className)}
33
+ role="tablist"
34
+ style={{ borderRadius: radius }}
35
+ data-size={normalizeSize(size)}
36
+ >
37
+ {items.map((item) => {
38
+ const active = item.value === current;
39
+ return (
40
+ <button
41
+ key={String(item.value)}
42
+ type="button"
43
+ role="tab"
44
+ aria-selected={active}
45
+ disabled={disabled || item.disabled}
46
+ className={cn(
47
+ "inline-flex cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap border-0 transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-45",
48
+ block ? "min-w-0 flex-1" : "shrink-0",
49
+ active ? "bg-brand-200 text-foreground" : "bg-transparent text-foreground hover:bg-brand-100",
50
+ )}
51
+ style={{
52
+ height: `max(22px, calc(${controlHeightVar(size)} - 4px))`,
53
+ paddingLeft: itemPadX,
54
+ paddingRight: itemPadX,
55
+ fontSize: tok.fontSize,
56
+ borderRadius: `max(4px, calc(${radius} - 2px))`,
57
+ }}
58
+ onClick={() => onChange?.(item.value)}
59
+ >
60
+ {item.icon}
61
+ {item.label}
62
+ </button>
63
+ );
64
+ })}
65
+ </div>
66
+ );
67
+ }