@orsetra/shared-ui 1.3.13 → 1.3.15

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.
@@ -1,6 +1,6 @@
1
1
  "use client"
2
2
 
3
- import React, { useState, useEffect } from "react"
3
+ import React, { useState, useEffect, useRef } from "react"
4
4
  import { Input } from "./input"
5
5
  import { Button } from "./button"
6
6
  import { X, Plus } from "lucide-react"
@@ -18,6 +18,8 @@ interface KVInputProps {
18
18
  valuePlaceholder?: string
19
19
  }
20
20
 
21
+ const BTN = "rounded-none h-9 w-9 p-0 flex-shrink-0"
22
+
21
23
  export function KVInput({
22
24
  value = {},
23
25
  onChange,
@@ -25,93 +27,103 @@ export function KVInput({
25
27
  keyPlaceholder = "Key",
26
28
  valuePlaceholder = "Value",
27
29
  }: KVInputProps) {
28
- const [pairs, setPairs] = useState<KVPair[]>(() => {
29
- const entries = Object.entries(value)
30
- return entries.length > 0 ? entries.map(([key, value]) => ({ key, value })) : [{ key: "", value: "" }]
31
- })
30
+ const [committed, setCommitted] = useState<KVPair[]>(() =>
31
+ Object.entries(value).map(([k, v]) => ({ key: k, value: v }))
32
+ )
33
+ const [draft, setDraft] = useState<KVPair>({ key: "", value: "" })
34
+ const skipSync = useRef(false)
32
35
 
36
+ // Sync from parent only when value changes externally (not from our own emit)
33
37
  useEffect(() => {
34
- if (value && Object.keys(value).length > 0) {
35
- const newPairs = Object.entries(value).map(([key, val]) => ({ key, value: val }))
36
- if (JSON.stringify(newPairs) !== JSON.stringify(pairs)) {
37
- setPairs(newPairs)
38
- }
39
- }
38
+ if (skipSync.current) { skipSync.current = false; return }
39
+ setCommitted(Object.entries(value).map(([k, v]) => ({ key: k, value: v })))
40
+ setDraft({ key: "", value: "" })
40
41
  }, [value])
41
42
 
42
- const handleChange = (index: number, field: "key" | "value", newValue: string) => {
43
- const newPairs = [...pairs]
44
- newPairs[index][field] = newValue
45
- setPairs(newPairs)
46
-
43
+ const emit = (comm: KVPair[]) => {
47
44
  const result: Record<string, string> = {}
48
- newPairs.forEach(pair => {
49
- if (pair.key) {
50
- result[pair.key] = pair.value
51
- }
52
- })
45
+ comm.forEach(p => { if (p.key) result[p.key] = p.value })
46
+ skipSync.current = true
53
47
  onChange?.(result)
54
48
  }
55
49
 
56
- const handleAdd = () => {
57
- setPairs([...pairs, { key: "", value: "" }])
50
+ const handleCommit = () => {
51
+ if (!draft.key) return
52
+ const next = [...committed, { ...draft }]
53
+ setCommitted(next)
54
+ setDraft({ key: "", value: "" })
55
+ emit(next)
58
56
  }
59
57
 
60
58
  const handleRemove = (index: number) => {
61
- const newPairs = pairs.filter((_, i) => i !== index)
62
- const finalPairs = newPairs.length > 0 ? newPairs : [{ key: "", value: "" }]
63
- setPairs(finalPairs)
64
-
65
- const result: Record<string, string> = {}
66
- finalPairs.forEach(pair => {
67
- if (pair.key) {
68
- result[pair.key] = pair.value
69
- }
70
- })
71
- onChange?.(result)
59
+ const next = committed.filter((_, i) => i !== index)
60
+ setCommitted(next)
61
+ emit(next)
62
+ }
63
+
64
+ const handleCommittedChange = (index: number, field: "key" | "value", val: string) => {
65
+ const next = committed.map((p, i) => i === index ? { ...p, [field]: val } : p)
66
+ setCommitted(next)
67
+ emit(next)
72
68
  }
73
69
 
74
70
  return (
75
71
  <div className="space-y-2">
76
- {pairs.map((pair, index) => (
72
+ {committed.map((pair, index) => (
77
73
  <div key={index} className="flex gap-2">
78
74
  <Input
79
75
  value={pair.key}
80
- onChange={(e) => handleChange(index, "key", e.target.value)}
76
+ onChange={(e) => handleCommittedChange(index, "key", e.target.value)}
81
77
  disabled={disabled}
82
78
  placeholder={keyPlaceholder}
83
79
  className="rounded-none flex-1"
84
80
  />
85
81
  <Input
86
82
  value={pair.value}
87
- onChange={(e) => handleChange(index, "value", e.target.value)}
83
+ onChange={(e) => handleCommittedChange(index, "value", e.target.value)}
88
84
  disabled={disabled}
89
85
  placeholder={valuePlaceholder}
90
86
  className="rounded-none flex-1"
91
87
  />
92
- {pairs.length > 1 && (
93
- <Button
94
- type="button"
95
- variant="secondary"
96
- onClick={() => handleRemove(index)}
97
- disabled={disabled}
98
- className="rounded-none h-9 w-9 p-0"
99
- >
100
- <X className="h-4 w-4" />
101
- </Button>
102
- )}
88
+ <Button
89
+ type="button"
90
+ variant="secondary"
91
+ onClick={() => handleRemove(index)}
92
+ disabled={disabled}
93
+ className={BTN}
94
+ >
95
+ <X className="h-4 w-4" />
96
+ </Button>
103
97
  </div>
104
98
  ))}
105
- <Button
106
- type="button"
107
- variant="secondary"
108
- onClick={handleAdd}
109
- disabled={disabled}
110
- className="rounded-none"
111
- >
112
- <Plus className="h-4 w-4 mr-2" />
113
- Add Item
114
- </Button>
99
+
100
+ {/* Draft row — + enabled only when key is filled */}
101
+ <div className="flex gap-2">
102
+ <Input
103
+ value={draft.key}
104
+ onChange={(e) => setDraft(d => ({ ...d, key: e.target.value }))}
105
+ disabled={disabled}
106
+ placeholder={keyPlaceholder}
107
+ required
108
+ className="rounded-none flex-1"
109
+ />
110
+ <Input
111
+ value={draft.value}
112
+ onChange={(e) => setDraft(d => ({ ...d, value: e.target.value }))}
113
+ disabled={disabled}
114
+ placeholder={valuePlaceholder}
115
+ className="rounded-none flex-1"
116
+ />
117
+ <Button
118
+ type="button"
119
+ variant="secondary"
120
+ onClick={handleCommit}
121
+ disabled={disabled || !draft.key}
122
+ className={BTN}
123
+ >
124
+ <Plus className="h-4 w-4" />
125
+ </Button>
126
+ </div>
115
127
  </div>
116
128
  )
117
129
  }
@@ -20,7 +20,7 @@ const SidePanelOverlay = React.forwardRef<
20
20
  <DialogPrimitive.Overlay
21
21
  ref={ref}
22
22
  className={cn(
23
- "fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
23
+ "fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:duration-300 data-[state=closed]:duration-200",
24
24
  className
25
25
  )}
26
26
  {...props}
@@ -31,8 +31,9 @@ SidePanelOverlay.displayName = DialogPrimitive.Overlay.displayName
31
31
  interface SidePanelProps {
32
32
  open: boolean
33
33
  onOpenChange: (open: boolean) => void
34
- title: string
35
- description?: string
34
+ title: React.ReactNode
35
+ description?: React.ReactNode
36
+ headerExtra?: React.ReactNode
36
37
  children: React.ReactNode
37
38
  actions: React.ReactNode
38
39
  side?: "left" | "right"
@@ -45,6 +46,7 @@ const SidePanel = ({
45
46
  onOpenChange,
46
47
  title,
47
48
  description,
49
+ headerExtra,
48
50
  children,
49
51
  actions,
50
52
  side = "right",
@@ -57,7 +59,7 @@ const SidePanel = ({
57
59
  <SidePanelOverlay />
58
60
  <DialogPrimitive.Content
59
61
  className={cn(
60
- "fixed z-50 bg-white shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 flex flex-col",
62
+ "fixed z-50 bg-white shadow-lg transition-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:ease-out data-[state=closed]:ease-in data-[state=open]:duration-300 data-[state=closed]:duration-200 flex flex-col",
61
63
  side === "right" &&
62
64
  "inset-y-0 right-0 h-full w-full border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
63
65
  side === "left" &&
@@ -77,6 +79,11 @@ const SidePanel = ({
77
79
  </DialogPrimitive.Description>
78
80
  )}
79
81
  </div>
82
+ {headerExtra && (
83
+ <div className="flex items-center mr-3">
84
+ {headerExtra}
85
+ </div>
86
+ )}
80
87
  <DialogPrimitive.Close
81
88
  className="rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ibm-blue-60 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-ibm-gray-10"
82
89
  onClick={onClose}
@@ -117,7 +124,7 @@ const SidePanelContent = React.forwardRef<
117
124
  <DialogPrimitive.Content
118
125
  ref={ref}
119
126
  className={cn(
120
- "fixed z-50 bg-white shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 flex flex-col",
127
+ "fixed z-50 bg-white shadow-lg transition-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:ease-out data-[state=closed]:ease-in data-[state=open]:duration-300 data-[state=closed]:duration-200 flex flex-col",
121
128
  side === "right" &&
122
129
  "inset-y-0 right-0 h-full w-full border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
123
130
  side === "left" &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orsetra/shared-ui",
3
- "version": "1.3.13",
3
+ "version": "1.3.15",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",