@parca/profile 0.16.442 → 0.16.444

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.
@@ -0,0 +1,321 @@
1
+ // Copyright 2022 The Parca Authors
2
+ // Licensed under the Apache License, Version 2.0 (the "License");
3
+ // you may not use this file except in compliance with the License.
4
+ // You may obtain a copy of the License at
5
+ //
6
+ // http://www.apache.org/licenses/LICENSE-2.0
7
+ //
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+
14
+ import {useEffect, useMemo, useRef, useState} from 'react';
15
+
16
+ import {Icon} from '@iconify/react';
17
+ import cx from 'classnames';
18
+ import * as d3 from 'd3';
19
+
20
+ export interface DataPoint {
21
+ timestamp: number;
22
+ value: number;
23
+ }
24
+
25
+ export type NumberDuo = [number, number];
26
+
27
+ interface Props {
28
+ width: number;
29
+ height: number;
30
+ marginLeft?: number;
31
+ marginRight?: number;
32
+ marginTop?: number;
33
+ marginBottom?: number;
34
+ fill?: string;
35
+ data: DataPoint[];
36
+ selectionBounds?: NumberDuo | undefined;
37
+ setSelectionBounds: (newBounds: NumberDuo | undefined) => void;
38
+ }
39
+
40
+ const DraggingWindow = ({
41
+ dragStart,
42
+ currentX,
43
+ }: {
44
+ dragStart: number | undefined;
45
+ currentX: number | undefined;
46
+ }): JSX.Element | null => {
47
+ const start = useMemo(() => Math.min(dragStart ?? 0, currentX ?? 0), [dragStart, currentX]);
48
+ const width = useMemo(() => Math.abs((dragStart ?? 0) - (currentX ?? 0)), [dragStart, currentX]);
49
+
50
+ if (dragStart === undefined || currentX === undefined) {
51
+ return null;
52
+ }
53
+
54
+ return (
55
+ <div
56
+ style={{height: '100%', width, left: start}}
57
+ className={cx(
58
+ 'bg-gray-500 absolute top-0 opacity-50 border-x-2 border-gray-900 dark:border-gray-100'
59
+ )}
60
+ ></div>
61
+ );
62
+ };
63
+
64
+ const ZoomWindow = ({
65
+ zoomWindow,
66
+ width,
67
+ onZoomWindowChange,
68
+ setIsHoveringDragHandle,
69
+ }: {
70
+ zoomWindow?: NumberDuo;
71
+ width: number;
72
+ onZoomWindowChange: (newWindow: NumberDuo) => void;
73
+ setIsHoveringDragHandle: (arg: boolean) => void;
74
+ }): JSX.Element | null => {
75
+ const windowStartHandleRef = useRef<HTMLDivElement>(null);
76
+ const windowEndHandleRef = useRef<HTMLDivElement>(null);
77
+ const [zoomWindowState, setZoomWindowState] = useState<NumberDuo | undefined>(zoomWindow);
78
+ const [dragginStart, setDraggingStart] = useState(false);
79
+ const [draggingEnd, setDraggingEnd] = useState(false);
80
+
81
+ useEffect(() => {
82
+ if (
83
+ zoomWindow === undefined ||
84
+ zoomWindowState === undefined ||
85
+ zoomWindow[0] !== zoomWindowState[0] ||
86
+ zoomWindow[1] !== zoomWindowState[1]
87
+ ) {
88
+ setZoomWindowState(zoomWindow);
89
+ }
90
+
91
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
+ }, [zoomWindow]);
93
+
94
+ if (zoomWindowState === undefined) {
95
+ return null;
96
+ }
97
+ const beforeStart = 0;
98
+ const beforeWidth = zoomWindowState[0];
99
+ const afterStart = zoomWindowState[1];
100
+ const afterWidth = width - zoomWindowState[1];
101
+
102
+ return (
103
+ <div
104
+ className="absolute w-full h-full"
105
+ onMouseMove={e => {
106
+ if (dragginStart) {
107
+ const [x] = d3.pointer(e);
108
+ if (x >= afterStart - 10) {
109
+ return;
110
+ }
111
+ const newStart = Math.min(x, afterStart);
112
+ const newEnd = Math.max(x, afterStart);
113
+ setZoomWindowState([newStart, newEnd]);
114
+ }
115
+ if (draggingEnd) {
116
+ const [x] = d3.pointer(e);
117
+ if (x <= beforeWidth + 10) {
118
+ return;
119
+ }
120
+ const newStart = Math.min(x, beforeWidth);
121
+ const newEnd = Math.max(x, beforeWidth);
122
+ setZoomWindowState([newStart, newEnd]);
123
+ }
124
+ }}
125
+ onMouseLeave={() => {
126
+ setDraggingStart(false);
127
+ setDraggingEnd(false);
128
+ }}
129
+ onMouseUp={() => {
130
+ if (dragginStart) {
131
+ setDraggingStart(false);
132
+ }
133
+ if (draggingEnd) {
134
+ setDraggingEnd(false);
135
+ }
136
+ if (zoomWindowState[0] === zoomWindow?.[0] && zoomWindowState[1] === zoomWindow?.[1]) {
137
+ return;
138
+ }
139
+ onZoomWindowChange(zoomWindowState);
140
+ setZoomWindowState(undefined);
141
+ }}
142
+ >
143
+ <div
144
+ style={{height: '100%', width: beforeWidth, left: beforeStart}}
145
+ className={cx(
146
+ 'bg-gray-500/50 absolute top-0 border-r-2 border-gray-900 dark:border-gray-100 z-20'
147
+ )}
148
+ >
149
+ <div
150
+ className="w-3 h-4 absolute top-0 right-[-7px] rounded-b bg-gray-200 cursor-ew-resize flex justify-center"
151
+ onMouseDown={e => {
152
+ setDraggingStart(true);
153
+ e.stopPropagation();
154
+ e.preventDefault();
155
+ }}
156
+ ref={windowStartHandleRef}
157
+ onMouseEnter={() => {
158
+ setIsHoveringDragHandle(true);
159
+ }}
160
+ onMouseLeave={() => {
161
+ setIsHoveringDragHandle(false);
162
+ }}
163
+ >
164
+ <Icon icon="si:drag-handle-line" className="rotate-90" fontSize={16} />
165
+ </div>
166
+ </div>
167
+
168
+ <div
169
+ style={{height: '100%', width: afterWidth, left: afterStart}}
170
+ className={cx(
171
+ 'bg-gray-500/50 absolute top-0 border-l-2 border-gray-900 dark:border-gray-100'
172
+ )}
173
+ >
174
+ <div
175
+ className="w-3 h-4 absolute top-0 rounded-b bg-gray-200 cursor-ew-resize flex justify-center left-[-7px]"
176
+ onMouseDown={e => {
177
+ setDraggingEnd(true);
178
+ e.stopPropagation();
179
+ e.preventDefault();
180
+ }}
181
+ ref={windowEndHandleRef}
182
+ onMouseEnter={() => {
183
+ setIsHoveringDragHandle(true);
184
+ }}
185
+ onMouseLeave={() => {
186
+ setIsHoveringDragHandle(false);
187
+ }}
188
+ >
189
+ <Icon icon="si:drag-handle-line" className="rotate-90" fontSize={16} />
190
+ </div>
191
+ </div>
192
+ </div>
193
+ );
194
+ };
195
+
196
+ export const AreaGraph = ({
197
+ data,
198
+ height,
199
+ width,
200
+ marginLeft = 0,
201
+ marginRight = 0,
202
+ marginBottom = 0,
203
+ marginTop = 0,
204
+ fill = 'gray',
205
+ selectionBounds,
206
+ setSelectionBounds,
207
+ }: Props): JSX.Element => {
208
+ const [mousePosition, setMousePosition] = useState<NumberDuo | undefined>(undefined);
209
+ const [dragStart, setDragStart] = useState<number | undefined>(undefined);
210
+ const [isHoveringDragHandle, setIsHoveringDragHandle] = useState(false);
211
+ const isDragging = dragStart !== undefined;
212
+
213
+ // Declare the x (horizontal position) scale.
214
+ const x = d3.scaleUtc(d3.extent(data, d => d.timestamp) as NumberDuo, [
215
+ marginLeft,
216
+ width - marginRight,
217
+ ]);
218
+
219
+ // Declare the y (vertical position) scale.
220
+ const y = d3.scaleLinear(
221
+ [0, d3.max(data, d => d.value) as number],
222
+ [height - marginBottom, marginTop]
223
+ );
224
+ const area = d3
225
+ .area<DataPoint>()
226
+ .curve(d3.curveMonotoneX)
227
+ .x(d => x(d.timestamp))
228
+ .y0(y(0))
229
+ .y1(d => y(d.value));
230
+
231
+ const zoomWindow: NumberDuo | undefined = useMemo(() => {
232
+ if (selectionBounds === undefined) {
233
+ return undefined;
234
+ }
235
+ return [x(selectionBounds[0]), x(selectionBounds[1])];
236
+
237
+ // eslint-disable-next-line react-hooks/exhaustive-deps
238
+ }, [selectionBounds]);
239
+
240
+ const setSelectionBoundsWithScaling = ([startPx, endPx]: NumberDuo): void => {
241
+ setSelectionBounds([x.invert(startPx).getTime(), x.invert(endPx).getTime()]);
242
+ };
243
+
244
+ return (
245
+ <div
246
+ style={{height, width}}
247
+ onMouseMove={e => {
248
+ const [x, y] = d3.pointer(e);
249
+ setMousePosition([x, y]);
250
+ }}
251
+ onMouseLeave={() => {
252
+ setMousePosition(undefined);
253
+ setDragStart(undefined);
254
+ }}
255
+ onMouseDown={e => {
256
+ // only left mouse button
257
+ if (e.button !== 0) {
258
+ return;
259
+ }
260
+
261
+ // X/Y coordinate array relative to svg
262
+ const rel = d3.pointer(e);
263
+
264
+ const xCoordinate = rel[0];
265
+ const xCoordinateWithoutMargin = xCoordinate - marginLeft;
266
+ if (xCoordinateWithoutMargin >= 0) {
267
+ setDragStart(xCoordinateWithoutMargin);
268
+ }
269
+
270
+ e.stopPropagation();
271
+ e.preventDefault();
272
+ }}
273
+ onMouseUp={e => {
274
+ if (dragStart === undefined) {
275
+ return;
276
+ }
277
+
278
+ const rel = d3.pointer(e);
279
+ const xCoordinate = rel[0];
280
+ const xCoordinateWithoutMargin = xCoordinate - marginLeft;
281
+ if (xCoordinateWithoutMargin >= 0 && dragStart !== xCoordinateWithoutMargin) {
282
+ const start = Math.min(dragStart, xCoordinateWithoutMargin);
283
+ const end = Math.max(dragStart, xCoordinateWithoutMargin);
284
+ setSelectionBoundsWithScaling([start, end]);
285
+ }
286
+ setDragStart(undefined);
287
+ }}
288
+ className="relative"
289
+ >
290
+ {/* onHover guide, only visible when hovering and not dragging and not having an active zoom window */}
291
+ <div
292
+ style={{height, width: 2, left: mousePosition?.[0] ?? -1}}
293
+ className={cx('bg-gray-700/75 dark:bg-gray-200/75 absolute top-0', {
294
+ hidden: mousePosition === undefined || isDragging || isHoveringDragHandle,
295
+ })}
296
+ ></div>
297
+
298
+ {/* drag guide, only visible when dragging */}
299
+ <DraggingWindow dragStart={dragStart} currentX={mousePosition?.[0]} />
300
+
301
+ {/* zoom window */}
302
+ <ZoomWindow
303
+ zoomWindow={zoomWindow}
304
+ width={width}
305
+ onZoomWindowChange={setSelectionBoundsWithScaling}
306
+ setIsHoveringDragHandle={setIsHoveringDragHandle}
307
+ />
308
+
309
+ {/* Inactive indicator */}
310
+ <div
311
+ className={cx('absolute top-0 left-0 w-full h-full bg-gray-900/50 dark:bg-gray-200/50', {
312
+ hidden: isDragging || selectionBounds !== undefined,
313
+ })}
314
+ ></div>
315
+
316
+ <svg style={{width: '100%', height: '100%'}}>
317
+ <path fill={fill} d={area(data) as string} className="opacity-80" />
318
+ </svg>
319
+ </div>
320
+ );
321
+ };
@@ -0,0 +1,57 @@
1
+ // Copyright 2022 The Parca Authors
2
+ // Licensed under the Apache License, Version 2.0 (the "License");
3
+ // you may not use this file except in compliance with the License.
4
+ // You may obtain a copy of the License at
5
+ //
6
+ // http://www.apache.org/licenses/LICENSE-2.0
7
+ //
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+
14
+ // eslint-disable-next-line import/named
15
+ import {useArgs} from '@storybook/preview-api';
16
+ // eslint-disable-next-line import/named
17
+ import {Meta} from '@storybook/react';
18
+
19
+ import {DataPoint, NumberDuo} from './AreaGraph';
20
+ import {MetricsGraphStrips} from './index';
21
+
22
+ const mockData: DataPoint[][] = [[], [], []];
23
+
24
+ for (let i = 0; i < 200; i++) {
25
+ for (let j = 0; j < mockData.length; j++) {
26
+ mockData[j].push({
27
+ timestamp: 1731326092000 + i * 100,
28
+ value: Math.floor(Math.random() * 100),
29
+ });
30
+ }
31
+ }
32
+ const meta: Meta = {
33
+ title: 'components/MetricsGraphStrips',
34
+ component: MetricsGraphStrips,
35
+ };
36
+ export default meta;
37
+
38
+ export const ThreeCPUStrips = {
39
+ args: {
40
+ cpus: Array.from(mockData, (_, i) => `CPU ${i + 1}`),
41
+ data: mockData,
42
+ selectedTimeline: {index: 1, bounds: [mockData[0][25].timestamp, mockData[0][100].timestamp]},
43
+ onSelectedTimeline: (index: number, bounds: NumberDuo): void => {
44
+ console.log('onSelectedTimeline', index, bounds);
45
+ },
46
+ },
47
+ render: function Component(args: any): JSX.Element {
48
+ const [, setArgs] = useArgs();
49
+
50
+ const onSelectedTimeline = (index: number, bounds: NumberDuo): void => {
51
+ args.onSelectedTimeline(index, bounds);
52
+ setArgs({...args, selectedTimeline: {index, bounds}});
53
+ };
54
+
55
+ return <MetricsGraphStrips {...args} onSelectedTimeline={onSelectedTimeline} />;
56
+ },
57
+ };
@@ -0,0 +1,111 @@
1
+ // Copyright 2022 The Parca Authors
2
+ // Licensed under the Apache License, Version 2.0 (the "License");
3
+ // you may not use this file except in compliance with the License.
4
+ // You may obtain a copy of the License at
5
+ //
6
+ // http://www.apache.org/licenses/LICENSE-2.0
7
+ //
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+
14
+ import {Fragment, useMemo} from 'react';
15
+
16
+ import * as d3 from 'd3';
17
+
18
+ import {DataPoint, NumberDuo} from '../AreaGraph';
19
+
20
+ interface Props {
21
+ width: number;
22
+ height: number;
23
+ margin: number;
24
+ data: DataPoint[][];
25
+ }
26
+
27
+ const alignBeforeAxisCorrection = (val: number): number => {
28
+ if (val < 10000) {
29
+ return -24;
30
+ }
31
+ if (val < 100000) {
32
+ return -28;
33
+ }
34
+
35
+ return 0;
36
+ };
37
+
38
+ export const TimelineGuide = ({data, width, height, margin}: Props): JSX.Element => {
39
+ const bounds = useMemo(() => {
40
+ const bounds: NumberDuo = [Infinity, -Infinity];
41
+ data.forEach(cpuData => {
42
+ cpuData.forEach(dataPoint => {
43
+ bounds[0] = Math.min(bounds[0], dataPoint.timestamp);
44
+ bounds[1] = Math.max(bounds[1], dataPoint.timestamp);
45
+ });
46
+ });
47
+ return [0, bounds[1] - bounds[0]];
48
+ }, [data]);
49
+
50
+ const xScale = d3.scaleLinear().domain(bounds).range([0, width]);
51
+
52
+ return (
53
+ <div className="relative h-4">
54
+ <div className="absolute" style={{width, height}}>
55
+ <svg style={{width: '100%', height: '100%'}}>
56
+ <g
57
+ className="x axis"
58
+ fill="none"
59
+ fontSize="10"
60
+ textAnchor="middle"
61
+ transform={`translate(0,${height - margin})`}
62
+ >
63
+ {xScale.ticks().map((d, i) => (
64
+ <Fragment key={`${i.toString()}-${d.toString()}`}>
65
+ <g
66
+ key={`tick-${i}`}
67
+ className="tick"
68
+ /* eslint-disable-next-line @typescript-eslint/restrict-template-expressions */
69
+ transform={`translate(${xScale(d) + alignBeforeAxisCorrection(d)}, ${-height})`}
70
+ >
71
+ {/* <line y2={6} className="stroke-gray-300 dark:stroke-gray-500" /> */}
72
+ <text fill="currentColor" dy=".71em" y={9}>
73
+ {d} ms
74
+ </text>
75
+ </g>
76
+ <g key={`grid-${i}`}>
77
+ <line
78
+ className="stroke-gray-300 dark:stroke-gray-500"
79
+ x1={xScale(d)}
80
+ x2={xScale(d)}
81
+ y1={0}
82
+ y2={-height + margin}
83
+ />
84
+ </g>
85
+ </Fragment>
86
+ ))}
87
+ <line
88
+ className="stroke-gray-300 dark:stroke-gray-500"
89
+ x1={0}
90
+ x2={width}
91
+ y1={-height + 1}
92
+ y2={-height + 1}
93
+ />
94
+ <line
95
+ className="stroke-gray-300 dark:stroke-gray-500"
96
+ x1={0}
97
+ x2={width}
98
+ y1={-height + 20}
99
+ y2={-height + 20}
100
+ />
101
+ {/* <g transform={`translate(${(width - 2.5 * margin) / 2}, ${margin / 2})`}>
102
+ <text fill="currentColor" dy=".71em" y={5} className="text-sm">
103
+ Time
104
+ </text>
105
+ </g> */}
106
+ </g>
107
+ </svg>
108
+ </div>
109
+ </div>
110
+ );
111
+ };
@@ -0,0 +1,93 @@
1
+ // Copyright 2022 The Parca Authors
2
+ // Licensed under the Apache License, Version 2.0 (the "License");
3
+ // you may not use this file except in compliance with the License.
4
+ // You may obtain a copy of the License at
5
+ //
6
+ // http://www.apache.org/licenses/LICENSE-2.0
7
+ //
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+
14
+ import {useState} from 'react';
15
+
16
+ import {Icon} from '@iconify/react';
17
+ import * as d3 from 'd3';
18
+
19
+ import {AreaGraph, DataPoint, NumberDuo} from './AreaGraph';
20
+ import {TimelineGuide} from './TimelineGuide';
21
+
22
+ interface Props {
23
+ cpus: string[];
24
+ data: DataPoint[][];
25
+ selectedTimeline?: {
26
+ index: number;
27
+ bounds: NumberDuo;
28
+ };
29
+ onSelectedTimeline: (index: number, bounds: NumberDuo | undefined) => void;
30
+ }
31
+
32
+ const getTimelineGuideHeight = (cpus: string[], collapsedIndices: number[]): number => {
33
+ return 56 * (cpus.length - collapsedIndices.length) + 20 * collapsedIndices.length + 24;
34
+ };
35
+
36
+ export const MetricsGraphStrips = ({
37
+ cpus,
38
+ data,
39
+ selectedTimeline,
40
+ onSelectedTimeline,
41
+ }: Props): JSX.Element => {
42
+ const [collapsedIndices, setCollapsedIndices] = useState<number[]>([]);
43
+
44
+ // @ts-expect-error
45
+ const color = d3.scaleOrdinal(d3.schemeObservable10);
46
+
47
+ return (
48
+ <div className="flex flex-col gap-1 relative">
49
+ <TimelineGuide
50
+ data={data}
51
+ width={1468}
52
+ height={getTimelineGuideHeight(cpus, collapsedIndices)}
53
+ margin={1}
54
+ />
55
+ {cpus.map((cpu, i) => {
56
+ const isCollapsed = collapsedIndices.includes(i);
57
+ return (
58
+ <div className="relative min-h-5" key={cpu}>
59
+ <div
60
+ className="text-xs absolute top-0 left-0 flex gap-[2px] items-center bg-white/50 px-1 rounded-sm cursor-pointer z-30"
61
+ onClick={() => {
62
+ const newCollapsedIndices = [...collapsedIndices];
63
+ if (collapsedIndices.includes(i)) {
64
+ newCollapsedIndices.splice(newCollapsedIndices.indexOf(i), 1);
65
+ } else {
66
+ newCollapsedIndices.push(i);
67
+ }
68
+ setCollapsedIndices(newCollapsedIndices);
69
+ }}
70
+ >
71
+ <Icon icon={isCollapsed ? 'bxs:right-arrow' : 'bxs:down-arrow'} />
72
+ {cpu}
73
+ </div>
74
+ {!isCollapsed ? (
75
+ <AreaGraph
76
+ data={data[i]}
77
+ height={56}
78
+ width={1468}
79
+ fill={color(i.toString()) as string}
80
+ selectionBounds={
81
+ selectedTimeline?.index === i ? selectedTimeline.bounds : undefined
82
+ }
83
+ setSelectionBounds={bounds => {
84
+ onSelectedTimeline(i, bounds);
85
+ }}
86
+ />
87
+ ) : null}
88
+ </div>
89
+ );
90
+ })}
91
+ </div>
92
+ );
93
+ };
@@ -131,7 +131,11 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
131
131
  setIsOpen(true);
132
132
  } else if (focusedIndex !== -1) {
133
133
  onSelection(filteredItems[focusedIndex].key);
134
- setIsOpen(false);
134
+ if (editable) {
135
+ setSearchTerm(filteredItems[focusedIndex].key);
136
+ } else {
137
+ setIsOpen(false);
138
+ }
135
139
  }
136
140
  } else if (e.key === 'Escape') {
137
141
  setIsOpen(false);
@@ -169,6 +173,16 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
169
173
  }
170
174
  };
171
175
 
176
+ const handleSelection = (value: string): void => {
177
+ onSelection(value);
178
+ if (editable) {
179
+ setSearchTerm(value);
180
+ setIsOpen(true);
181
+ } else {
182
+ setIsOpen(false);
183
+ }
184
+ };
185
+
172
186
  return (
173
187
  <div ref={containerRef} className="relative" onKeyDown={handleKeyDown} onClick={onButtonClick}>
174
188
  <div
@@ -209,38 +223,41 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
209
223
  >
210
224
  {searchable && (
211
225
  <div className="sticky z-10 top-[-5px] w-auto max-w-full">
212
- <div className={cx('relative', editable ? 'h-full min-h-[50px]' : 'h-[45px]')}>
226
+ <div className="flex flex-col">
213
227
  {editable ? (
214
- <textarea
215
- ref={searchInputRef as React.LegacyRef<HTMLTextAreaElement>}
216
- className="w-full px-4 py-2 h-full text-sm border-b border-gray-200 rounded-none ring-0 outline-none bg-gray-50 dark:bg-gray-800 dark:text-white"
217
- placeholder={editable ? 'Type a RegEx to add' : 'Search...'}
218
- value={searchTerm}
219
- onChange={e => setSearchTerm(e.target.value)}
220
- />
228
+ <>
229
+ <textarea
230
+ ref={searchInputRef as React.LegacyRef<HTMLTextAreaElement>}
231
+ className="w-full px-4 py-2 text-sm border-b border-gray-200 rounded-none ring-0 outline-none bg-gray-50 dark:bg-gray-800 dark:text-white min-h-[50px]"
232
+ placeholder="Type a RegEx to add"
233
+ value={searchTerm}
234
+ onChange={e => setSearchTerm(e.target.value)}
235
+ />
236
+ {editable && searchTerm.length > 0 && (
237
+ <div className="p-2 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
238
+ <Button
239
+ variant="primary"
240
+ className="w-full h-[30px]"
241
+ onClick={() => {
242
+ onSelection(searchTerm);
243
+ setIsOpen(false);
244
+ }}
245
+ >
246
+ Add
247
+ </Button>
248
+ </div>
249
+ )}
250
+ </>
221
251
  ) : (
222
252
  <input
223
253
  ref={searchInputRef as React.LegacyRef<HTMLInputElement>}
224
254
  type="text"
225
- className="w-full px-4 h-full text-sm border-none rounded-none ring-0 outline-none bg-gray-50 dark:bg-gray-800 dark:text-white"
226
- placeholder={editable ? 'Type a RegEx to add' : 'Search...'}
255
+ className="w-full px-4 h-[45px] text-sm border-none rounded-none ring-0 outline-none bg-gray-50 dark:bg-gray-800 dark:text-white"
256
+ placeholder="Search..."
227
257
  value={searchTerm}
228
258
  onChange={e => setSearchTerm(e.target.value)}
229
259
  />
230
260
  )}
231
-
232
- {editable && searchTerm.length > 0 && (
233
- <Button
234
- variant="neutral"
235
- className="absolute bottom-[10px] right-[10px] h-[30px]"
236
- onClick={() => {
237
- onSelection(searchTerm);
238
- setIsOpen(false);
239
- }}
240
- >
241
- Add{' '}
242
- </Button>
243
- )}
244
261
  </div>
245
262
  </div>
246
263
  )}
@@ -269,8 +286,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
269
286
  tabIndex={-1}
270
287
  onClick={() => {
271
288
  if (!(item.disabled ?? false)) {
272
- onSelection(item.key);
273
- setIsOpen(false);
289
+ handleSelection(item.key);
274
290
  }
275
291
  }}
276
292
  >