@1889ca/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/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@1889ca/ui",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.js",
6
+ "module": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./tokens": "./src/styles/tokens.css"
10
+ },
11
+ "sideEffects": [
12
+ "*.css"
13
+ ],
14
+ "files": [
15
+ "src"
16
+ ],
17
+ "scripts": {
18
+ "gallery": "vite"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "peerDependencies": {
24
+ "react": ">=18.0.0",
25
+ "react-dom": ">=18.0.0"
26
+ }
27
+ }
@@ -0,0 +1,50 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+
3
+ .ui-ctx-menu {
4
+ position: fixed;
5
+ z-index: 1000;
6
+ min-width: 170px;
7
+ background: rgba(18, 18, 24, 0.82);
8
+ backdrop-filter: blur(var(--ui-glass-blur-heavy));
9
+ border: 1px solid var(--ui-border);
10
+ border-top-color: var(--ui-border-strong);
11
+ border-radius: var(--ui-radius);
12
+ padding: 4px;
13
+ box-shadow: var(--ui-shadow-float);
14
+ animation: ui-ctx-in 0.12s var(--ui-ease);
15
+ }
16
+
17
+ @keyframes ui-ctx-in {
18
+ from { opacity: 0; transform: scale(0.96); }
19
+ to { opacity: 1; transform: scale(1); }
20
+ }
21
+
22
+ .ui-ctx-menu-item {
23
+ display: block;
24
+ width: 100%;
25
+ padding: 7px 12px;
26
+ background: none;
27
+ border: none;
28
+ color: var(--ui-text);
29
+ font-size: 0.82rem;
30
+ text-align: left;
31
+ border-radius: var(--ui-radius-sm);
32
+ cursor: pointer;
33
+ transition: background 0.12s, color 0.12s;
34
+ }
35
+
36
+ .ui-ctx-menu-item:hover {
37
+ background: var(--ui-accent-muted);
38
+ color: #fff;
39
+ }
40
+
41
+ .ui-ctx-menu-item--danger:hover {
42
+ background: var(--ui-danger-glow);
43
+ color: var(--ui-danger);
44
+ }
45
+
46
+ .ui-ctx-menu-sep {
47
+ height: 1px;
48
+ background: var(--ui-border);
49
+ margin: 4px 8px;
50
+ }
@@ -0,0 +1,54 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+ import { useEffect, useRef } from 'react';
3
+
4
+ /**
5
+ * Right-click context menu. Renders at (x, y) with a list of action items.
6
+ * Items: [{ label, action, danger?, separator? }]
7
+ */
8
+ export default function ContextMenu({ x, y, items, onClose }) {
9
+ const ref = useRef(null);
10
+
11
+ useEffect(() => {
12
+ function onMouseDown(e) {
13
+ if (ref.current && !ref.current.contains(e.target)) onClose();
14
+ }
15
+ function onKey(e) {
16
+ if (e.key === 'Escape') onClose();
17
+ }
18
+ document.addEventListener('mousedown', onMouseDown);
19
+ document.addEventListener('keydown', onKey);
20
+ return () => {
21
+ document.removeEventListener('mousedown', onMouseDown);
22
+ document.removeEventListener('keydown', onKey);
23
+ };
24
+ }, [onClose]);
25
+
26
+ useEffect(() => {
27
+ if (!ref.current) return;
28
+ const rect = ref.current.getBoundingClientRect();
29
+ if (rect.right > window.innerWidth) {
30
+ ref.current.style.left = `${x - rect.width}px`;
31
+ }
32
+ if (rect.bottom > window.innerHeight) {
33
+ ref.current.style.top = `${y - rect.height}px`;
34
+ }
35
+ }, [x, y]);
36
+
37
+ return (
38
+ <div ref={ref} className="ui-ctx-menu" style={{ left: x, top: y }}>
39
+ {items.map((item, i) =>
40
+ item.separator ? (
41
+ <div key={i} className="ui-ctx-menu-sep" />
42
+ ) : (
43
+ <button
44
+ key={i}
45
+ className={`ui-ctx-menu-item${item.danger ? ' ui-ctx-menu-item--danger' : ''}`}
46
+ onClick={() => { item.action(); onClose(); }}
47
+ >
48
+ {item.label}
49
+ </button>
50
+ )
51
+ )}
52
+ </div>
53
+ );
54
+ }
@@ -0,0 +1,73 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+
3
+ .ui-dp-panel {
4
+ position: absolute;
5
+ z-index: 30;
6
+ background: rgba(18, 18, 24, 0.8);
7
+ backdrop-filter: blur(var(--ui-glass-blur-heavy));
8
+ border: 1px solid var(--ui-border);
9
+ border-top-color: var(--ui-border-strong);
10
+ border-radius: var(--ui-radius);
11
+ box-shadow: var(--ui-shadow-float);
12
+ display: flex;
13
+ flex-direction: column;
14
+ min-width: 120px;
15
+ user-select: none;
16
+ }
17
+
18
+ .ui-dp-titlebar {
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: space-between;
22
+ padding: 0.4rem 0.65rem;
23
+ cursor: grab;
24
+ border-bottom: 1px solid var(--ui-border);
25
+ flex-shrink: 0;
26
+ }
27
+
28
+ .ui-dp-titlebar:active {
29
+ cursor: grabbing;
30
+ }
31
+
32
+ .ui-dp-title {
33
+ font-size: 0.7rem;
34
+ font-weight: 600;
35
+ color: var(--ui-text-muted);
36
+ text-transform: uppercase;
37
+ letter-spacing: 0.06em;
38
+ }
39
+
40
+ .ui-dp-close {
41
+ background: none;
42
+ border: none;
43
+ color: var(--ui-text-subtle);
44
+ font-size: 0.9rem;
45
+ cursor: pointer;
46
+ line-height: 1;
47
+ padding: 0.1rem 0.2rem;
48
+ border-radius: var(--ui-radius-sm);
49
+ transition: color 0.15s, background 0.15s;
50
+ }
51
+
52
+ .ui-dp-close:hover {
53
+ color: var(--ui-text);
54
+ background: var(--ui-surface-raised);
55
+ }
56
+
57
+ .ui-dp-body {
58
+ flex: 1;
59
+ min-height: 0;
60
+ }
61
+
62
+ .ui-dp-resize-handle {
63
+ height: 6px;
64
+ cursor: ns-resize;
65
+ background: transparent;
66
+ border-top: 1px solid var(--ui-border);
67
+ flex-shrink: 0;
68
+ transition: background 0.15s;
69
+ }
70
+
71
+ .ui-dp-resize-handle:hover {
72
+ background: var(--ui-accent-muted);
73
+ }
@@ -0,0 +1,66 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+ import { useRef, useState, useEffect } from 'react';
3
+
4
+ /**
5
+ * Floating panel with drag-to-move titlebar and optional vertical resize.
6
+ * Position persists in localStorage keyed by panelId.
7
+ * @param {string} storagePrefix - localStorage key prefix (default: 'ui-panel')
8
+ */
9
+ export default function DraggablePanel({ panelId, title, defaultPos, resizable, minHeight, maxHeight, className, children, onClose, storagePrefix = 'ui-panel' }) {
10
+ const [pos, setPos] = useState(() => {
11
+ try { const s = localStorage.getItem(`${storagePrefix}-${panelId}`); return s ? JSON.parse(s) : defaultPos; }
12
+ catch { return defaultPos; }
13
+ });
14
+ const [height, setHeight] = useState(() => {
15
+ try { const s = localStorage.getItem(`${storagePrefix}-h-${panelId}`); return s ? Number(s) : (minHeight || 200); }
16
+ catch { return minHeight || 200; }
17
+ });
18
+ const dragRef = useRef(null);
19
+ const resizeRef = useRef(null);
20
+
21
+ useEffect(() => {
22
+ try { localStorage.setItem(`${storagePrefix}-${panelId}`, JSON.stringify(pos)); } catch {}
23
+ }, [pos, panelId, storagePrefix]);
24
+
25
+ useEffect(() => {
26
+ if (resizable) try { localStorage.setItem(`${storagePrefix}-h-${panelId}`, String(height)); } catch {}
27
+ }, [height, panelId, resizable, storagePrefix]);
28
+
29
+ function handleDragStart(e) {
30
+ if (e.target.closest('.ui-dp-close') || e.target.closest('button') || e.target.closest('input') || e.target.closest('select')) return;
31
+ e.preventDefault();
32
+ const origin = { mx: e.clientX, my: e.clientY, px: pos.x, py: pos.y };
33
+ dragRef.current = origin;
34
+ function onMove(me) { setPos({ x: origin.px + me.clientX - origin.mx, y: origin.py + me.clientY - origin.my }); }
35
+ function onUp() { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }
36
+ window.addEventListener('mousemove', onMove);
37
+ window.addEventListener('mouseup', onUp);
38
+ }
39
+
40
+ function handleResizeStart(e) {
41
+ e.preventDefault(); e.stopPropagation();
42
+ const origin = { my: e.clientY, h: height };
43
+ resizeRef.current = origin;
44
+ function onMove(me) {
45
+ const nh = Math.max(minHeight || 100, Math.min(maxHeight || 600, origin.h + me.clientY - origin.my));
46
+ setHeight(nh);
47
+ }
48
+ function onUp() { resizeRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }
49
+ window.addEventListener('mousemove', onMove);
50
+ window.addEventListener('mouseup', onUp);
51
+ }
52
+
53
+ return (
54
+ <div className={`ui-dp-panel ${className || ''}`} style={{ left: pos.x, top: pos.y }}
55
+ onMouseDown={(e) => e.stopPropagation()}>
56
+ <div className="ui-dp-titlebar" onMouseDown={handleDragStart}>
57
+ <span className="ui-dp-title">{title}</span>
58
+ {onClose && <button className="ui-dp-close" type="button" onClick={onClose}>×</button>}
59
+ </div>
60
+ <div className="ui-dp-body" style={resizable ? { height, overflow: 'auto' } : undefined}>
61
+ {children}
62
+ </div>
63
+ {resizable && <div className="ui-dp-resize-handle" onMouseDown={handleResizeStart} />}
64
+ </div>
65
+ );
66
+ }
@@ -0,0 +1,32 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+
3
+ .ui-ev-display {
4
+ cursor: pointer;
5
+ font-size: 0.8rem;
6
+ color: var(--ui-text-muted);
7
+ font-variant-numeric: tabular-nums;
8
+ border-radius: var(--ui-radius-sm);
9
+ padding: 0.15rem 0.35rem;
10
+ transition: all 0.15s var(--ui-ease);
11
+ }
12
+
13
+ .ui-ev-display:hover {
14
+ background: var(--ui-surface-raised);
15
+ color: var(--ui-text);
16
+ box-shadow: 0 0 0 1px var(--ui-border);
17
+ }
18
+
19
+ .ui-ev-input {
20
+ font: inherit;
21
+ font-size: 0.8rem;
22
+ font-variant-numeric: tabular-nums;
23
+ background: var(--ui-surface);
24
+ backdrop-filter: blur(var(--ui-glass-blur));
25
+ border: 1px solid var(--ui-accent);
26
+ box-shadow: 0 0 0 3px var(--ui-accent-subtle);
27
+ border-radius: var(--ui-radius-sm);
28
+ color: var(--ui-text);
29
+ padding: 0.15rem 0.35rem;
30
+ outline: none;
31
+ width: 4.5rem;
32
+ }
@@ -0,0 +1,82 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+ import { useState, useRef } from 'react';
3
+
4
+ /**
5
+ * Click-to-edit numeric value display. Shows formatted text normally,
6
+ * switches to an input on click. Commits on Enter/blur, cancels on Escape.
7
+ * Up/Down arrows increment/decrement by step (Shift = 10x).
8
+ */
9
+ export default function EditableValue({ value, onChange, suffix = '', min, max, step = 1, displayValue }) {
10
+ const [editing, setEditing] = useState(false);
11
+ const [draft, setDraft] = useState('');
12
+ const inputRef = useRef(null);
13
+
14
+ function startEdit(e) {
15
+ e.stopPropagation();
16
+ setDraft(displayValue ?? formatValue(value, suffix, step));
17
+ setEditing(true);
18
+ setTimeout(() => { inputRef.current?.select(); }, 0);
19
+ }
20
+
21
+ function clamp(n) {
22
+ if (min !== undefined) n = Math.max(min, n);
23
+ if (max !== undefined) n = Math.min(max, n);
24
+ if (step >= 1) n = Math.round(n);
25
+ else n = Math.round(n / step) * step;
26
+ return n;
27
+ }
28
+
29
+ function commit() {
30
+ setEditing(false);
31
+ const raw = draft.replace(/[^0-9.\-]/g, '');
32
+ let n = parseFloat(raw);
33
+ if (isNaN(n)) return;
34
+ if (suffix === '%' && step < 1) n = n / 100;
35
+ onChange(clamp(n));
36
+ }
37
+
38
+ function nudge(direction, shiftKey) {
39
+ const multiplier = shiftKey ? 10 : 1;
40
+ const increment = suffix === '%' && step < 1 ? 0.01 * multiplier : step * multiplier;
41
+ onChange(clamp(value + direction * increment));
42
+ }
43
+
44
+ function handleKeyDown(e) {
45
+ if (e.key === 'Enter') { e.preventDefault(); commit(); }
46
+ else if (e.key === 'Escape') setEditing(false);
47
+ else if (e.key === 'ArrowUp') { e.preventDefault(); nudge(1, e.shiftKey); setDraft(formatValue(clamp(value + (suffix === '%' && step < 1 ? (e.shiftKey ? 0.1 : 0.01) : step * (e.shiftKey ? 10 : 1))), suffix, step)); }
48
+ else if (e.key === 'ArrowDown') { e.preventDefault(); nudge(-1, e.shiftKey); setDraft(formatValue(clamp(value - (suffix === '%' && step < 1 ? (e.shiftKey ? 0.1 : 0.01) : step * (e.shiftKey ? 10 : 1))), suffix, step)); }
49
+ e.stopPropagation();
50
+ }
51
+
52
+ if (editing) {
53
+ return (
54
+ <input
55
+ ref={inputRef}
56
+ className="ui-ev-input"
57
+ value={draft}
58
+ onChange={(e) => setDraft(e.target.value)}
59
+ onBlur={commit}
60
+ onKeyDown={handleKeyDown}
61
+ onClick={(e) => e.stopPropagation()}
62
+ onMouseDown={(e) => e.stopPropagation()}
63
+ autoFocus
64
+ />
65
+ );
66
+ }
67
+
68
+ const display = displayValue ?? formatValue(value, suffix, step);
69
+ return (
70
+ <span className="ui-ev-display" onClick={startEdit} title="Click to edit">
71
+ {display}
72
+ </span>
73
+ );
74
+ }
75
+
76
+ function formatValue(v, suffix, step) {
77
+ if (suffix === '%' && step < 1) return `${Math.round(v * 100)}%`;
78
+ if (suffix === '°') return `${Math.round(v)}°`;
79
+ if (suffix === 'px') return `${Math.round(v)}px`;
80
+ if (step < 1) return v.toFixed(2);
81
+ return `${Math.round(v)}`;
82
+ }
@@ -0,0 +1,131 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+
3
+ .ui-folder-wrap {
4
+ position: relative;
5
+ border-radius: var(--ui-radius);
6
+ transition: box-shadow 0.2s var(--ui-ease);
7
+ }
8
+
9
+ .ui-folder-wrap.ui-folder-drop-active {
10
+ box-shadow: inset 0 0 0 2px var(--ui-accent), 0 0 20px var(--ui-accent-glow);
11
+ border-radius: var(--ui-radius);
12
+ }
13
+
14
+ .ui-folder {
15
+ display: flex;
16
+ flex-direction: column;
17
+ width: 100%;
18
+ background: none;
19
+ border: 1px solid var(--ui-border);
20
+ border-radius: var(--ui-radius);
21
+ padding: 0;
22
+ cursor: pointer;
23
+ overflow: hidden;
24
+ transition: border-color 0.2s var(--ui-ease), transform 0.2s var(--ui-ease-spring), box-shadow 0.2s var(--ui-ease);
25
+ color: var(--ui-text);
26
+ }
27
+
28
+ .ui-folder:hover {
29
+ border-color: var(--ui-border-strong);
30
+ transform: translateY(-2px) scale(1.01);
31
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
32
+ }
33
+
34
+ .ui-folder-icon {
35
+ aspect-ratio: 1;
36
+ background: linear-gradient(
37
+ 135deg,
38
+ var(--ui-accent-subtle) 0%,
39
+ transparent 60%
40
+ ),
41
+ var(--ui-surface-solid);
42
+ border-bottom: 1px solid var(--ui-border-subtle);
43
+ display: flex;
44
+ align-items: center;
45
+ justify-content: center;
46
+ overflow: hidden;
47
+ position: relative;
48
+ }
49
+
50
+ /* Folder tab shape */
51
+ .ui-folder-icon::before {
52
+ content: '';
53
+ position: absolute;
54
+ top: 0;
55
+ left: 0;
56
+ width: 40%;
57
+ height: 6px;
58
+ background: linear-gradient(90deg, var(--ui-accent), transparent);
59
+ border-radius: 0 0 6px 0;
60
+ opacity: 0.6;
61
+ }
62
+
63
+ .ui-folder-info {
64
+ display: flex;
65
+ flex-direction: column;
66
+ gap: 0.1rem;
67
+ padding: 0.45rem 0.6rem;
68
+ backdrop-filter: blur(var(--ui-glass-blur));
69
+ background: var(--ui-surface);
70
+ }
71
+
72
+ .ui-folder-name {
73
+ font-size: 0.8rem;
74
+ font-weight: 600;
75
+ white-space: nowrap;
76
+ overflow: hidden;
77
+ text-overflow: ellipsis;
78
+ text-align: left;
79
+ }
80
+
81
+ .ui-folder-count {
82
+ font-size: 0.62rem;
83
+ color: var(--ui-text-subtle);
84
+ text-align: left;
85
+ }
86
+
87
+ /* Folder preview thumbnails */
88
+ .ui-folder-preview-grid {
89
+ width: 80%;
90
+ aspect-ratio: 1;
91
+ border-radius: 8px;
92
+ overflow: hidden;
93
+ border: 1px solid var(--ui-border-subtle);
94
+ }
95
+
96
+ .ui-folder-preview-grid img {
97
+ width: 100%;
98
+ height: 100%;
99
+ object-fit: cover;
100
+ }
101
+
102
+ .ui-folder-preview-grid--1 { display: flex; }
103
+ .ui-folder-preview-grid--2 { display: grid; grid-template-columns: 1fr 1fr; }
104
+
105
+ .ui-folder-preview-grid--3 {
106
+ display: grid;
107
+ grid-template-columns: 1fr 1fr;
108
+ grid-template-rows: 1fr 1fr;
109
+ }
110
+ .ui-folder-preview-grid--3 img:first-child { grid-row: 1 / -1; }
111
+
112
+ .ui-folder-preview-grid--4 {
113
+ display: grid;
114
+ grid-template-columns: 1fr 1fr;
115
+ grid-template-rows: 1fr 1fr;
116
+ }
117
+
118
+ .ui-folder-preview-empty {
119
+ display: flex;
120
+ align-items: center;
121
+ justify-content: center;
122
+ width: 60%;
123
+ aspect-ratio: 1;
124
+ }
125
+
126
+ .ui-folder-preview-svg {
127
+ width: 48px;
128
+ height: 48px;
129
+ color: var(--ui-text-subtle);
130
+ opacity: 0.3;
131
+ }
@@ -0,0 +1,53 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+ import InlineEdit from './InlineEdit.jsx';
3
+
4
+ /**
5
+ * Folder card with thumbnail preview grid and inline rename.
6
+ *
7
+ * @param {string} name - Folder name
8
+ * @param {number} itemCount - Number of items
9
+ * @param {Array} thumbnails - Array of image URLs for preview (max 4 shown)
10
+ * @param {boolean} dropActive - Highlight when dragging over
11
+ * @param {function} onClick - Click to enter folder
12
+ * @param {function} onRename - Save new name
13
+ * @param {function} onDragOver
14
+ * @param {function} onDragLeave
15
+ * @param {function} onDrop
16
+ */
17
+ export default function FolderCard({ name, itemCount, thumbnails = [], dropActive, onClick, onRename, onDragOver, onDragLeave, onDrop }) {
18
+ const thumbs = thumbnails.slice(0, 4);
19
+ const count = thumbs.length;
20
+
21
+ return (
22
+ <li
23
+ className={`ui-folder-wrap${dropActive ? ' ui-folder-drop-active' : ''}`}
24
+ onDragOver={onDragOver}
25
+ onDragLeave={onDragLeave}
26
+ onDrop={onDrop}
27
+ >
28
+ <div className="ui-folder" onClick={onClick}>
29
+ <div className="ui-folder-icon">
30
+ {count === 0 ? (
31
+ <div className="ui-folder-preview-empty">
32
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="ui-folder-preview-svg">
33
+ <path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
34
+ </svg>
35
+ </div>
36
+ ) : (
37
+ <div className={`ui-folder-preview-grid ui-folder-preview-grid--${Math.min(count, 4)}`}>
38
+ {thumbs.map((url, i) => <img key={i} src={url} alt="" draggable={false} />)}
39
+ </div>
40
+ )}
41
+ </div>
42
+ <div className="ui-folder-info">
43
+ {onRename ? (
44
+ <InlineEdit value={name} onSave={onRename} className="ui-folder-name" />
45
+ ) : (
46
+ <span className="ui-folder-name">{name}</span>
47
+ )}
48
+ <span className="ui-folder-count">{itemCount} {itemCount === 1 ? 'item' : 'items'}</span>
49
+ </div>
50
+ </div>
51
+ </li>
52
+ );
53
+ }
@@ -0,0 +1,30 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+
3
+ .ui-inline-editable {
4
+ cursor: text;
5
+ border-radius: var(--ui-radius-sm);
6
+ padding: 0.1rem 0.25rem;
7
+ margin: -0.1rem -0.25rem;
8
+ transition: all 0.15s var(--ui-ease);
9
+ }
10
+
11
+ .ui-inline-editable:hover {
12
+ background: var(--ui-surface-raised);
13
+ box-shadow: 0 0 0 1px var(--ui-border);
14
+ }
15
+
16
+ .ui-inline-edit-input {
17
+ font: inherit;
18
+ font-weight: 600;
19
+ background: var(--ui-surface);
20
+ backdrop-filter: blur(var(--ui-glass-blur));
21
+ border: 1px solid var(--ui-accent);
22
+ box-shadow: 0 0 0 3px var(--ui-accent-subtle);
23
+ border-radius: var(--ui-radius-sm);
24
+ color: var(--ui-text);
25
+ padding: 0.1rem 0.25rem;
26
+ margin: -0.1rem -0.25rem;
27
+ outline: none;
28
+ width: 100%;
29
+ min-width: 60px;
30
+ }
@@ -0,0 +1,47 @@
1
+ /** Contract: contracts/packages-ui-components/rules.md */
2
+ import { useState, useRef } from 'react';
3
+
4
+ /**
5
+ * Double-click-to-edit text field. Shows a span normally, becomes an input on double-click.
6
+ * Commits on Enter/blur, cancels on Escape.
7
+ */
8
+ export default function InlineEdit({ value, onSave, className }) {
9
+ const [editing, setEditing] = useState(false);
10
+ const [draft, setDraft] = useState(value);
11
+ const inputRef = useRef(null);
12
+
13
+ function startEdit(e) {
14
+ e.stopPropagation();
15
+ setDraft(value);
16
+ setEditing(true);
17
+ setTimeout(() => inputRef.current?.select(), 0);
18
+ }
19
+
20
+ function commit() {
21
+ setEditing(false);
22
+ if (draft.trim() && draft.trim() !== value) {
23
+ onSave(draft.trim());
24
+ }
25
+ }
26
+
27
+ if (editing) {
28
+ return (
29
+ <input
30
+ ref={inputRef}
31
+ className="ui-inline-edit-input"
32
+ value={draft}
33
+ onChange={(e) => setDraft(e.target.value)}
34
+ onBlur={commit}
35
+ onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(false); }}
36
+ onClick={(e) => e.stopPropagation()}
37
+ autoFocus
38
+ />
39
+ );
40
+ }
41
+
42
+ return (
43
+ <span className={`${className || ''} ui-inline-editable`} onDoubleClick={startEdit} title="Double-click to rename">
44
+ {value}
45
+ </span>
46
+ );
47
+ }