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,371 @@
|
|
|
1
|
+
import { type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type ReactNode, type TextareaHTMLAttributes, forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import { type ControlSize, controlFieldFocusBorder, controlFieldStyle, controlFieldSurface, controlFieldTransition, controlHeightVar, controlRadiusVar, controlStatusClass, getSizeTokens, normalizeSize } from "../lib/sizes";
|
|
4
|
+
|
|
5
|
+
export type InputSize = ControlSize;
|
|
6
|
+
|
|
7
|
+
export type InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "prefix"> & {
|
|
8
|
+
size?: InputSize;
|
|
9
|
+
status?: "error" | "warning";
|
|
10
|
+
prefix?: ReactNode;
|
|
11
|
+
suffix?: ReactNode;
|
|
12
|
+
allowClear?: boolean;
|
|
13
|
+
variant?: "outlined" | "borderless" | "filled";
|
|
14
|
+
/** antd — fires on Enter (ignored while composing). */
|
|
15
|
+
onPressEnter?: (e: KeyboardEvent<HTMLInputElement>) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const fieldBase = cn("w-full", controlFieldSurface, "placeholder:text-quaternary-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-45", controlFieldFocusBorder);
|
|
19
|
+
|
|
20
|
+
function variantClass(variant: InputProps["variant"]) {
|
|
21
|
+
if (variant === "borderless") return "border-transparent bg-transparent hover:bg-transparent shadow-none focus:border-transparent";
|
|
22
|
+
if (variant === "filled") return "border-transparent bg-muted focus:border-transparent focus:bg-[var(--control-bg-hover)]";
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
|
|
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);
|
|
28
|
+
const showClear = allowClear && !disabled && value != null && String(value).length > 0;
|
|
29
|
+
const wrapped = Boolean(prefix || suffix || showClear);
|
|
30
|
+
|
|
31
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
32
|
+
onKeyDown?.(e);
|
|
33
|
+
if (e.key === "Enter" && !e.nativeEvent.isComposing) onPressEnter?.(e);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const input = (
|
|
37
|
+
<input
|
|
38
|
+
ref={ref}
|
|
39
|
+
disabled={disabled}
|
|
40
|
+
value={value}
|
|
41
|
+
onChange={onChange}
|
|
42
|
+
onKeyDown={handleKeyDown}
|
|
43
|
+
className={cn(fieldBase, controlFieldTransition, controlStatusClass(status), variantClass(variant), wrapped && "border-0 bg-transparent h-full focus:bg-transparent p-0!", !wrapped && className)}
|
|
44
|
+
style={wrapped ? { height: "100%", fontSize: fieldStyle.fontSize, lineHeight: fieldStyle.lineHeight, ...style } : { ...fieldStyle, ...style }}
|
|
45
|
+
{...rest}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (!wrapped) return input;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className={cn("inline-flex w-full items-center gap-2", controlFieldSurface, controlFieldTransition, controlStatusClass(status), variantClass(variant), "focus-within:outline-none", controlFieldFocusBorder, disabled && "opacity-45 cursor-not-allowed", className)}
|
|
54
|
+
style={fieldStyle}
|
|
55
|
+
>
|
|
56
|
+
{prefix ? <span className="shrink-0 text-muted-foreground inline-flex items-center">{prefix}</span> : null}
|
|
57
|
+
{input}
|
|
58
|
+
{showClear ? (
|
|
59
|
+
<button type="button" className="shrink-0 text-muted-foreground hover:text-foreground text-xs leading-none" aria-label="Clear" onClick={() => onChange?.({ target: { value: "" } } as never)}>
|
|
60
|
+
×
|
|
61
|
+
</button>
|
|
62
|
+
) : null}
|
|
63
|
+
{suffix ? <span className="shrink-0 text-muted-foreground inline-flex items-center">{suffix}</span> : null}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
|
69
|
+
status?: "error" | "warning";
|
|
70
|
+
variant?: "outlined" | "borderless" | "filled";
|
|
71
|
+
autoSize?: boolean | { minRows?: number; maxRows?: number };
|
|
72
|
+
size?: ControlSize;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** antd-compatible ref used by chat InputArea (`resizableTextArea.textArea`). */
|
|
76
|
+
export type TextAreaRef = HTMLTextAreaElement & {
|
|
77
|
+
resizableTextArea?: { textArea: HTMLTextAreaElement };
|
|
78
|
+
};
|
|
79
|
+
|
|
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);
|
|
82
|
+
const innerRef = useRef<HTMLTextAreaElement | null>(null);
|
|
83
|
+
const minRows = typeof autoSize === "object" ? (autoSize.minRows ?? 1) : autoSize ? 1 : undefined;
|
|
84
|
+
const maxRows = typeof autoSize === "object" ? autoSize.maxRows : undefined;
|
|
85
|
+
const padY = 16;
|
|
86
|
+
|
|
87
|
+
const resize = useCallback(() => {
|
|
88
|
+
const el = innerRef.current;
|
|
89
|
+
if (!el || !autoSize) return;
|
|
90
|
+
const minH = (minRows ?? 1) * tok.lineHeight + padY;
|
|
91
|
+
const maxH = maxRows != null ? maxRows * tok.lineHeight + padY : Number.POSITIVE_INFINITY;
|
|
92
|
+
el.style.height = "auto";
|
|
93
|
+
const next = Math.min(Math.max(el.scrollHeight, minH), maxH);
|
|
94
|
+
el.style.height = `${next}px`;
|
|
95
|
+
el.style.overflowY = el.scrollHeight > maxH ? "auto" : "hidden";
|
|
96
|
+
}, [autoSize, minRows, maxRows, tok.lineHeight]);
|
|
97
|
+
|
|
98
|
+
useLayoutEffect(() => {
|
|
99
|
+
resize();
|
|
100
|
+
}, [resize, value]);
|
|
101
|
+
|
|
102
|
+
useImperativeHandle(ref, () => {
|
|
103
|
+
const el = innerRef.current as TextAreaRef;
|
|
104
|
+
if (el) el.resizableTextArea = { textArea: el };
|
|
105
|
+
return el;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<textarea
|
|
110
|
+
ref={innerRef}
|
|
111
|
+
rows={rows ?? minRows ?? 3}
|
|
112
|
+
value={value}
|
|
113
|
+
onChange={(e) => {
|
|
114
|
+
onChange?.(e);
|
|
115
|
+
requestAnimationFrame(resize);
|
|
116
|
+
}}
|
|
117
|
+
className={cn(fieldBase, "py-2", autoSize ? "resize-none" : "resize-y", controlStatusClass(status), variantClass(variant), className)}
|
|
118
|
+
style={
|
|
119
|
+
{
|
|
120
|
+
minHeight: autoSize ? (minRows ?? 1) * tok.lineHeight + padY : controlHeightVar(size),
|
|
121
|
+
fontSize: tok.fontSize,
|
|
122
|
+
lineHeight: `${tok.lineHeight}px`,
|
|
123
|
+
paddingLeft: tok.paddingInline,
|
|
124
|
+
paddingRight: tok.paddingInline,
|
|
125
|
+
borderRadius: controlRadiusVar(size),
|
|
126
|
+
...style,
|
|
127
|
+
...(maxRows && !autoSize ? { maxHeight: maxRows * tok.lineHeight + padY } : null),
|
|
128
|
+
} satisfies CSSProperties
|
|
129
|
+
}
|
|
130
|
+
{...rest}
|
|
131
|
+
/>
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
export type InputNumberProps = Omit<InputProps, "type" | "onChange" | "value" | "defaultValue" | "prefix" | "suffix" | "allowClear" | "variant"> & {
|
|
136
|
+
value?: number | null;
|
|
137
|
+
defaultValue?: number;
|
|
138
|
+
onChange?: (value: number | null) => void;
|
|
139
|
+
min?: number;
|
|
140
|
+
max?: number;
|
|
141
|
+
step?: number;
|
|
142
|
+
/** Show antd-style up/down handlers (default true). */
|
|
143
|
+
controls?: boolean;
|
|
144
|
+
/** Precision after decimal; omit to keep free typing. */
|
|
145
|
+
precision?: number;
|
|
146
|
+
parser?: (displayValue: string) => number;
|
|
147
|
+
formatter?: (value: number | string | undefined) => string;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
function clamp(n: number, min?: number, max?: number) {
|
|
151
|
+
let v = n;
|
|
152
|
+
if (min != null && v < min) v = min;
|
|
153
|
+
if (max != null && v > max) v = max;
|
|
154
|
+
return v;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function roundTo(n: number, precision?: number) {
|
|
158
|
+
if (precision == null) return n;
|
|
159
|
+
const f = 10 ** precision;
|
|
160
|
+
return Math.round(n * f) / f;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function HandlerChevron({ dir }: { dir: "up" | "down" }) {
|
|
164
|
+
return (
|
|
165
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden className="opacity-70">
|
|
166
|
+
{dir === "up" ? <path d="M2.5 6.25L5 3.75L7.5 6.25" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /> : <path d="M2.5 3.75L5 6.25L7.5 3.75" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />}
|
|
167
|
+
</svg>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const InputNumber = forwardRef<HTMLInputElement, InputNumberProps>(function InputNumber({ value, defaultValue, onChange, min, max, step = 1, controls = true, precision, parser, formatter, disabled, size, status, className, style, placeholder, onBlur, onFocus, onKeyDown, ...rest }, ref) {
|
|
172
|
+
const controlled = value !== undefined;
|
|
173
|
+
const [inner, setInner] = useState<number | null>(defaultValue ?? null);
|
|
174
|
+
const numeric = controlled ? (value ?? null) : inner;
|
|
175
|
+
const [text, setText] = useState(() => (numeric == null ? "" : String(numeric)));
|
|
176
|
+
const [focused, setFocused] = useState(false);
|
|
177
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
178
|
+
const tok = getSizeTokens(size);
|
|
179
|
+
const fieldStyle = controlFieldStyle(size);
|
|
180
|
+
|
|
181
|
+
// Sync display from external value when not editing
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (focused) return;
|
|
184
|
+
if (numeric == null) setText("");
|
|
185
|
+
else setText(formatter ? formatter(numeric) : String(numeric));
|
|
186
|
+
}, [numeric, focused, formatter]);
|
|
187
|
+
|
|
188
|
+
const commit = useCallback(
|
|
189
|
+
(next: number | null) => {
|
|
190
|
+
let v = next;
|
|
191
|
+
if (v != null && Number.isFinite(v)) {
|
|
192
|
+
v = roundTo(clamp(v, min, max), precision);
|
|
193
|
+
} else {
|
|
194
|
+
v = null;
|
|
195
|
+
}
|
|
196
|
+
if (!controlled) setInner(v);
|
|
197
|
+
onChange?.(v);
|
|
198
|
+
setText(v == null ? "" : formatter ? formatter(v) : String(v));
|
|
199
|
+
},
|
|
200
|
+
[controlled, onChange, min, max, precision, formatter],
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const stepBy = (dir: 1 | -1) => {
|
|
204
|
+
if (disabled) return;
|
|
205
|
+
const base = numeric ?? min ?? 0;
|
|
206
|
+
commit(base + dir * step);
|
|
207
|
+
inputRef.current?.focus();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const parseRaw = (raw: string): number | null => {
|
|
211
|
+
if (raw.trim() === "") return null;
|
|
212
|
+
const n = parser ? parser(raw) : Number(raw.replace(/,/g, ""));
|
|
213
|
+
return Number.isFinite(n) ? n : null;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const atMin = numeric != null && min != null && numeric <= min;
|
|
217
|
+
const atMax = numeric != null && max != null && numeric >= max;
|
|
218
|
+
const handlerW = normalizeSize(size) === "small" ? 18 : 22;
|
|
219
|
+
|
|
220
|
+
const setRefs = (node: HTMLInputElement | null) => {
|
|
221
|
+
inputRef.current = node;
|
|
222
|
+
if (typeof ref === "function") ref(node);
|
|
223
|
+
else if (ref) (ref as { current: HTMLInputElement | null }).current = node;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
227
|
+
if (e.key === "ArrowUp") {
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
stepBy(1);
|
|
230
|
+
} else if (e.key === "ArrowDown") {
|
|
231
|
+
e.preventDefault();
|
|
232
|
+
stepBy(-1);
|
|
233
|
+
}
|
|
234
|
+
onKeyDown?.(e);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<div
|
|
239
|
+
className={cn("group relative inline-flex w-full items-stretch", controlFieldSurface, controlFieldTransition, controlFieldFocusBorder, controlStatusClass(status), disabled && "opacity-45 cursor-not-allowed", className)}
|
|
240
|
+
style={{ height: fieldStyle.height, borderRadius: fieldStyle.borderRadius, ...style }}
|
|
241
|
+
>
|
|
242
|
+
<input
|
|
243
|
+
{...rest}
|
|
244
|
+
ref={setRefs}
|
|
245
|
+
type="text"
|
|
246
|
+
inputMode="decimal"
|
|
247
|
+
role="spinbutton"
|
|
248
|
+
aria-valuenow={numeric ?? undefined}
|
|
249
|
+
aria-valuemin={min}
|
|
250
|
+
aria-valuemax={max}
|
|
251
|
+
disabled={disabled}
|
|
252
|
+
placeholder={placeholder}
|
|
253
|
+
value={text}
|
|
254
|
+
onChange={(e) => {
|
|
255
|
+
const raw = e.target.value;
|
|
256
|
+
setText(raw);
|
|
257
|
+
// Live update when parseable (antd-like); keep typing free otherwise
|
|
258
|
+
if (raw === "" || raw === "-" || raw === "." || raw === "-.") return;
|
|
259
|
+
const n = parseRaw(raw);
|
|
260
|
+
if (n != null) {
|
|
261
|
+
if (!controlled) setInner(n);
|
|
262
|
+
onChange?.(n);
|
|
263
|
+
}
|
|
264
|
+
}}
|
|
265
|
+
onFocus={(e) => {
|
|
266
|
+
setFocused(true);
|
|
267
|
+
onFocus?.(e);
|
|
268
|
+
}}
|
|
269
|
+
onBlur={(e) => {
|
|
270
|
+
setFocused(false);
|
|
271
|
+
commit(parseRaw(text));
|
|
272
|
+
onBlur?.(e);
|
|
273
|
+
}}
|
|
274
|
+
onKeyDown={handleKeyDown}
|
|
275
|
+
className={cn(
|
|
276
|
+
"min-w-0 flex-1 border-0 bg-transparent text-foreground placeholder:text-quaternary-foreground outline-none",
|
|
277
|
+
"disabled:cursor-not-allowed",
|
|
278
|
+
// Kill native number chrome if type ever switches
|
|
279
|
+
"[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
|
|
280
|
+
)}
|
|
281
|
+
style={{
|
|
282
|
+
height: "100%",
|
|
283
|
+
fontSize: fieldStyle.fontSize,
|
|
284
|
+
lineHeight: fieldStyle.lineHeight,
|
|
285
|
+
paddingLeft: tok.paddingInline,
|
|
286
|
+
paddingRight: controls ? handlerW + 4 : tok.paddingInline,
|
|
287
|
+
}}
|
|
288
|
+
/>
|
|
289
|
+
|
|
290
|
+
{controls && !disabled ? (
|
|
291
|
+
<div
|
|
292
|
+
className={cn("absolute top-0 right-0 bottom-0 flex flex-col overflow-hidden border-l border-border/80", "opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100")}
|
|
293
|
+
style={{ width: handlerW, borderTopRightRadius: `calc(${controlRadiusVar(size)} - 1px)`, borderBottomRightRadius: `calc(${controlRadiusVar(size)} - 1px)` }}
|
|
294
|
+
>
|
|
295
|
+
<button
|
|
296
|
+
type="button"
|
|
297
|
+
tabIndex={-1}
|
|
298
|
+
aria-label="Increase"
|
|
299
|
+
disabled={atMax}
|
|
300
|
+
className={cn("flex flex-1 items-center justify-center text-muted-foreground", "hover:bg-foreground/5 hover:text-foreground active:bg-foreground/8", "disabled:opacity-30 disabled:pointer-events-none", "border-b border-border/60")}
|
|
301
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
302
|
+
onClick={() => stepBy(1)}
|
|
303
|
+
>
|
|
304
|
+
<HandlerChevron dir="up" />
|
|
305
|
+
</button>
|
|
306
|
+
<button
|
|
307
|
+
type="button"
|
|
308
|
+
tabIndex={-1}
|
|
309
|
+
aria-label="Decrease"
|
|
310
|
+
disabled={atMin}
|
|
311
|
+
className={cn("flex flex-1 items-center justify-center text-muted-foreground", "hover:bg-foreground/5 hover:text-foreground active:bg-foreground/8", "disabled:opacity-30 disabled:pointer-events-none")}
|
|
312
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
313
|
+
onClick={() => stepBy(-1)}
|
|
314
|
+
>
|
|
315
|
+
<HandlerChevron dir="down" />
|
|
316
|
+
</button>
|
|
317
|
+
</div>
|
|
318
|
+
) : null}
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
export type PasswordProps = InputProps & {
|
|
324
|
+
visibilityToggle?: boolean | { visible?: boolean; onVisibleChange?: (visible: boolean) => void };
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
function EyeIcon({ off }: { off?: boolean }) {
|
|
328
|
+
return (
|
|
329
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
|
|
330
|
+
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z" stroke="currentColor" strokeWidth="1.75" />
|
|
331
|
+
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.75" />
|
|
332
|
+
{off ? <path d="M4 4L20 20" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" /> : null}
|
|
333
|
+
</svg>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const Password = forwardRef<HTMLInputElement, PasswordProps>(function Password({ visibilityToggle = true, suffix, ...props }, ref) {
|
|
338
|
+
const toggle = visibilityToggle;
|
|
339
|
+
const enabled = toggle !== false;
|
|
340
|
+
const controlled = typeof toggle === "object" && toggle.visible !== undefined;
|
|
341
|
+
const [inner, setInner] = useState(false);
|
|
342
|
+
const visible = enabled && (controlled ? Boolean((toggle as { visible?: boolean }).visible) : inner);
|
|
343
|
+
|
|
344
|
+
const setVisible = (v: boolean) => {
|
|
345
|
+
if (!controlled) setInner(v);
|
|
346
|
+
if (typeof toggle === "object") toggle.onVisibleChange?.(v);
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const eye = enabled ? (
|
|
350
|
+
<button type="button" tabIndex={-1} aria-label={visible ? "Hide password" : "Show password"} className="inline-flex items-center text-muted-foreground hover:text-foreground" onClick={() => setVisible(!visible)}>
|
|
351
|
+
<EyeIcon off={!visible} />
|
|
352
|
+
</button>
|
|
353
|
+
) : null;
|
|
354
|
+
|
|
355
|
+
const mergedSuffix =
|
|
356
|
+
suffix || eye ? (
|
|
357
|
+
<>
|
|
358
|
+
{suffix}
|
|
359
|
+
{eye}
|
|
360
|
+
</>
|
|
361
|
+
) : undefined;
|
|
362
|
+
|
|
363
|
+
return <InputRoot ref={ref} type={visible ? "text" : "password"} suffix={mergedSuffix} {...props} />;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
export const Input = Object.assign(InputRoot, {
|
|
367
|
+
TextArea,
|
|
368
|
+
Password,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
export { TextArea, InputNumber };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type ChangeEvent, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Input, type InputProps } from "./Input";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_WAIT = 300;
|
|
5
|
+
|
|
6
|
+
export type SearchInputProps = Omit<InputProps, "prefix" | "onChange" | "value" | "defaultValue"> & {
|
|
7
|
+
defaultValue?: string;
|
|
8
|
+
/** Debounced after typing stops for `wait` ms (trimmed). Clear and Enter flush immediately. */
|
|
9
|
+
onChange?: (value: string) => void;
|
|
10
|
+
/** Debounce delay in ms. Default 300. */
|
|
11
|
+
wait?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function SearchIcon({ size = 14 }: { size?: number }) {
|
|
15
|
+
return (
|
|
16
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden className="shrink-0 text-muted-foreground">
|
|
17
|
+
<circle cx="11" cy="11" r="6.25" stroke="currentColor" strokeWidth="1.75" />
|
|
18
|
+
<path d="M16 16L20.5 20.5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
|
|
19
|
+
</svg>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function SearchInput({ defaultValue = "", onChange, wait = DEFAULT_WAIT, allowClear = true, className, placeholder = "Search…", onPressEnter, ...rest }: SearchInputProps) {
|
|
24
|
+
const [value, setValue] = useState(defaultValue);
|
|
25
|
+
const onChangeRef = useRef(onChange);
|
|
26
|
+
onChangeRef.current = onChange;
|
|
27
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
28
|
+
|
|
29
|
+
const clearTimer = () => {
|
|
30
|
+
if (timerRef.current) {
|
|
31
|
+
clearTimeout(timerRef.current);
|
|
32
|
+
timerRef.current = null;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const flush = (raw: string) => {
|
|
37
|
+
clearTimer();
|
|
38
|
+
onChangeRef.current?.(raw.trim());
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const schedule = (raw: string) => {
|
|
42
|
+
clearTimer();
|
|
43
|
+
timerRef.current = setTimeout(() => {
|
|
44
|
+
timerRef.current = null;
|
|
45
|
+
onChangeRef.current?.(raw.trim());
|
|
46
|
+
}, wait);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
useEffect(() => clearTimer, []);
|
|
50
|
+
|
|
51
|
+
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
52
|
+
const next = e.target.value;
|
|
53
|
+
setValue(next);
|
|
54
|
+
if (next === "") flush("");
|
|
55
|
+
else schedule(next);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Input
|
|
60
|
+
{...rest}
|
|
61
|
+
allowClear={allowClear}
|
|
62
|
+
className={className}
|
|
63
|
+
placeholder={placeholder}
|
|
64
|
+
value={value}
|
|
65
|
+
onChange={handleChange}
|
|
66
|
+
prefix={<SearchIcon />}
|
|
67
|
+
onPressEnter={(e) => {
|
|
68
|
+
flush(value);
|
|
69
|
+
onPressEnter?.(e);
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
}
|
package/src/lib/cn.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type PopperPlacement =
|
|
2
|
+
| "top"
|
|
3
|
+
| "bottom"
|
|
4
|
+
| "left"
|
|
5
|
+
| "right"
|
|
6
|
+
| "topLeft"
|
|
7
|
+
| "topRight"
|
|
8
|
+
| "bottomLeft"
|
|
9
|
+
| "bottomRight"
|
|
10
|
+
| "leftTop"
|
|
11
|
+
| "leftBottom"
|
|
12
|
+
| "rightTop"
|
|
13
|
+
| "rightBottom";
|
|
14
|
+
|
|
15
|
+
export type RadixSide = "top" | "bottom" | "left" | "right";
|
|
16
|
+
export type RadixAlign = "start" | "center" | "end";
|
|
17
|
+
|
|
18
|
+
export function placementToRadix(placement?: PopperPlacement): { side: RadixSide; align: RadixAlign } {
|
|
19
|
+
switch (placement) {
|
|
20
|
+
case "topLeft":
|
|
21
|
+
return { side: "top", align: "start" };
|
|
22
|
+
case "topRight":
|
|
23
|
+
return { side: "top", align: "end" };
|
|
24
|
+
case "bottomLeft":
|
|
25
|
+
return { side: "bottom", align: "start" };
|
|
26
|
+
case "bottomRight":
|
|
27
|
+
return { side: "bottom", align: "end" };
|
|
28
|
+
case "leftTop":
|
|
29
|
+
return { side: "left", align: "start" };
|
|
30
|
+
case "leftBottom":
|
|
31
|
+
return { side: "left", align: "end" };
|
|
32
|
+
case "rightTop":
|
|
33
|
+
return { side: "right", align: "start" };
|
|
34
|
+
case "rightBottom":
|
|
35
|
+
return { side: "right", align: "end" };
|
|
36
|
+
case "top":
|
|
37
|
+
case "bottom":
|
|
38
|
+
case "left":
|
|
39
|
+
case "right":
|
|
40
|
+
return { side: placement, align: "center" };
|
|
41
|
+
default:
|
|
42
|
+
return { side: "bottom", align: "center" };
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/lib/sizes.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical control sizes for NonlaUI.
|
|
5
|
+
* Height + radius live as `--nonla-*` CSS knobs (theme). Other metrics stay here.
|
|
6
|
+
* Radius small/large = `--nonla-radius` ± 2px.
|
|
7
|
+
*/
|
|
8
|
+
export const CONTROL_SIZES = {
|
|
9
|
+
small: {
|
|
10
|
+
height: 24,
|
|
11
|
+
fontSize: 12,
|
|
12
|
+
lineHeight: 16,
|
|
13
|
+
paddingInline: 7,
|
|
14
|
+
paddingInlineIconStart: 8,
|
|
15
|
+
paddingInlineIconEnd: 8,
|
|
16
|
+
icon: 12,
|
|
17
|
+
radius: 6,
|
|
18
|
+
},
|
|
19
|
+
default: {
|
|
20
|
+
height: 32,
|
|
21
|
+
fontSize: 14,
|
|
22
|
+
lineHeight: 20,
|
|
23
|
+
paddingInline: 11,
|
|
24
|
+
paddingInlineIconStart: 11,
|
|
25
|
+
paddingInlineIconEnd: 11,
|
|
26
|
+
icon: 14,
|
|
27
|
+
radius: 8,
|
|
28
|
+
},
|
|
29
|
+
large: {
|
|
30
|
+
height: 40,
|
|
31
|
+
fontSize: 16,
|
|
32
|
+
lineHeight: 24,
|
|
33
|
+
paddingInline: 15,
|
|
34
|
+
paddingInlineIconStart: 15,
|
|
35
|
+
paddingInlineIconEnd: 15,
|
|
36
|
+
icon: 18,
|
|
37
|
+
radius: 10,
|
|
38
|
+
},
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
export type CanonicalSize = keyof typeof CONTROL_SIZES;
|
|
42
|
+
|
|
43
|
+
/** Public size prop — aliases map to the 3 canonical sizes. */
|
|
44
|
+
export type ControlSize = CanonicalSize | "middle" | "medium" | "xs";
|
|
45
|
+
|
|
46
|
+
export type ControlSizeTokens = (typeof CONTROL_SIZES)[CanonicalSize];
|
|
47
|
+
|
|
48
|
+
/** Map legacy / antd aliases → small | default | large */
|
|
49
|
+
export function normalizeSize(size: ControlSize | undefined): CanonicalSize {
|
|
50
|
+
if (size === "small" || size === "xs") return "small";
|
|
51
|
+
if (size === "large") return "large";
|
|
52
|
+
// default | middle | medium | undefined
|
|
53
|
+
return "default";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getSizeTokens(size: ControlSize | undefined): ControlSizeTokens {
|
|
57
|
+
return CONTROL_SIZES[normalizeSize(size)];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Live height from theme (`--nonla-height` / `-sm` / `-lg`). */
|
|
61
|
+
export function controlHeightVar(size: ControlSize | undefined): string {
|
|
62
|
+
const s = normalizeSize(size);
|
|
63
|
+
if (s === "small") return "var(--nonla-height-sm)";
|
|
64
|
+
if (s === "large") return "var(--nonla-height-lg)";
|
|
65
|
+
return "var(--nonla-height)";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Live radius from theme (`--nonla-radius`; sm = −2px, lg = +2px). */
|
|
69
|
+
export function controlRadiusVar(size: ControlSize | undefined): string {
|
|
70
|
+
const s = normalizeSize(size);
|
|
71
|
+
if (s === "small") return "var(--nonla-radius-sm)";
|
|
72
|
+
if (s === "large") return "var(--nonla-radius-lg)";
|
|
73
|
+
return "var(--nonla-radius)";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Soft status border — keep the same color on focus so brand focus does not override. */
|
|
77
|
+
export function controlStatusClass(status?: "error" | "warning"): string {
|
|
78
|
+
if (status === "error") return "border-[color-mix(in_oklab,var(--destructive)_55%,transparent)] focus:border-destructive focus-within:border-destructive data-[state=open]:border-destructive";
|
|
79
|
+
if (status === "warning") return "border-[color-mix(in_oklab,var(--warn)_55%,transparent)] focus:border-warn focus-within:border-warn data-[state=open]:border-warn";
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Focus surface fade — Input / Select / DatePicker / TimePicker. */
|
|
84
|
+
export const controlFieldTransition = "transition-[background-color,border-color] duration-[var(--nonla-dur-fast,150ms)] ease-[var(--nonla-ease-out,cubic-bezier(0.16,1,0.3,1))] motion-reduce:transition-none";
|
|
85
|
+
|
|
86
|
+
/** Outlined field fill — meadow glass, same language as DesktopWindow. */
|
|
87
|
+
export const controlFieldSurface =
|
|
88
|
+
"border border-solid border-glass-border bg-glass-bar text-foreground shadow-[inset_0_1px_0_var(--glass-highlight)] backdrop-blur-xl";
|
|
89
|
+
|
|
90
|
+
/** Focus / open border — brand (Button primary), keep fields outlined. */
|
|
91
|
+
export const controlFieldFocusBorder = "focus:border-brand focus-within:border-brand data-[state=open]:border-brand aria-expanded:border-brand";
|
|
92
|
+
|
|
93
|
+
/** Shared field chrome (Input / Select / DatePicker). */
|
|
94
|
+
export function controlFieldStyle(size: ControlSize | undefined): CSSProperties {
|
|
95
|
+
const t = getSizeTokens(size);
|
|
96
|
+
return {
|
|
97
|
+
height: controlHeightVar(size),
|
|
98
|
+
minHeight: controlHeightVar(size),
|
|
99
|
+
fontSize: t.fontSize,
|
|
100
|
+
lineHeight: `${t.lineHeight}px`,
|
|
101
|
+
paddingLeft: t.paddingInline,
|
|
102
|
+
paddingRight: t.paddingInline,
|
|
103
|
+
borderRadius: controlRadiusVar(size),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Square control width = height (icon-only buttons). */
|
|
108
|
+
export function controlSquareStyle(size: ControlSize | undefined): CSSProperties {
|
|
109
|
+
const t = getSizeTokens(size);
|
|
110
|
+
const h = controlHeightVar(size);
|
|
111
|
+
return { width: h, height: h, paddingLeft: 0, paddingRight: 0, borderRadius: controlRadiusVar(size), fontSize: t.fontSize };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function controlIconStyle(size: ControlSize | undefined): CSSProperties {
|
|
115
|
+
const t = getSizeTokens(size);
|
|
116
|
+
return { width: t.icon, height: t.icon };
|
|
117
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Frosted panel — window, modal, popover, menu, drawer, toast. */
|
|
2
|
+
export const glassSurfaceClass = "nonla-glass";
|
|
3
|
+
|
|
4
|
+
/** Floating overlay chrome (Popover / Dropdown / Select / pickers / Tooltip). */
|
|
5
|
+
export const glassOverlayClass = "z-[9999] rounded-xl nonla-glass outline-none nonla-popper";
|
|
6
|
+
|
|
7
|
+
/** Meadow menus — same panel as overlays. */
|
|
8
|
+
export const meadowSurfaceClass = "rounded-xl nonla-glass";
|