@firecms/ui 3.2.0 → 3.3.0-canary.451aa49
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/dist/components/BooleanSwitchWithLabel.d.ts +2 -1
- package/dist/components/Chip.d.ts +1 -1
- package/dist/components/ResizablePanels.d.ts +16 -0
- package/dist/components/SearchableSelect.d.ts +48 -0
- package/dist/components/Tabs.d.ts +8 -1
- package/dist/components/Tooltip.d.ts +18 -2
- package/dist/components/index.d.ts +2 -0
- package/dist/icons/FirestoreIcon.d.ts +6 -0
- package/dist/icons/components/DatabaseIcon.d.ts +6 -0
- package/dist/icons/index.d.ts +2 -0
- package/dist/index.es.js +1370 -476
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1372 -478
- package/dist/index.umd.js.map +1 -1
- package/package.json +2 -2
- package/src/components/BooleanSwitchWithLabel.tsx +4 -0
- package/src/components/Button.tsx +2 -1
- package/src/components/Chip.tsx +4 -3
- package/src/components/MultiSelect.tsx +23 -4
- package/src/components/ResizablePanels.tsx +181 -0
- package/src/components/SearchableSelect.tsx +335 -0
- package/src/components/Skeleton.tsx +4 -2
- package/src/components/Tabs.tsx +44 -16
- package/src/components/TextareaAutosize.tsx +77 -212
- package/src/components/Tooltip.tsx +7 -6
- package/src/components/index.tsx +2 -0
- package/src/icons/FirestoreIcon.tsx +47 -0
- package/src/icons/components/DatabaseIcon.tsx +10 -0
- package/src/icons/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firecms/ui",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.3.0-canary.451aa49",
|
|
5
5
|
"description": "Awesome Firebase/Firestore-based headless open-source CMS",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/firecmsco"
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"index.css",
|
|
115
115
|
"tailwind.config.js"
|
|
116
116
|
],
|
|
117
|
-
"gitHead": "
|
|
117
|
+
"gitHead": "772b4a7f64893038f0cc13669d6bc66ec858fc49",
|
|
118
118
|
"publishConfig": {
|
|
119
119
|
"access": "public"
|
|
120
120
|
}
|
|
@@ -18,6 +18,7 @@ export type BooleanSwitchWithLabelProps = BooleanSwitchProps & {
|
|
|
18
18
|
fullWidth?: boolean,
|
|
19
19
|
className?: string,
|
|
20
20
|
inputClassName?: string,
|
|
21
|
+
switchAdornment?: React.ReactNode,
|
|
21
22
|
};
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -37,6 +38,7 @@ export const BooleanSwitchWithLabel = function BooleanSwitchWithLabel({
|
|
|
37
38
|
className,
|
|
38
39
|
fullWidth = true,
|
|
39
40
|
inputClassName,
|
|
41
|
+
switchAdornment,
|
|
40
42
|
...props
|
|
41
43
|
}: BooleanSwitchWithLabelProps) {
|
|
42
44
|
|
|
@@ -100,6 +102,8 @@ export const BooleanSwitchWithLabel = function BooleanSwitchWithLabel({
|
|
|
100
102
|
{...props}
|
|
101
103
|
/>
|
|
102
104
|
|
|
105
|
+
{switchAdornment}
|
|
106
|
+
|
|
103
107
|
<div className={cls(
|
|
104
108
|
"flex-grow",
|
|
105
109
|
position === "end" ? "mr-4" : "ml-4",
|
|
@@ -28,6 +28,7 @@ const ButtonInner = React.memo(React.forwardRef<
|
|
|
28
28
|
fullWidth = false,
|
|
29
29
|
component: Component,
|
|
30
30
|
color = "neutral",
|
|
31
|
+
loading,
|
|
31
32
|
...props
|
|
32
33
|
}: ButtonProps<any>, ref) => {
|
|
33
34
|
|
|
@@ -63,7 +64,7 @@ const ButtonInner = React.memo(React.forwardRef<
|
|
|
63
64
|
"text-text-disabled dark:text-text-disabled-dark": disabled,
|
|
64
65
|
"border border-transparent opacity-50": variant === "text" && disabled,
|
|
65
66
|
"border border-surface-500 opacity-50": variant === "outlined" && disabled,
|
|
66
|
-
"border border-transparent bg-surface-300 dark:bg-surface-500 opacity-
|
|
67
|
+
"border border-transparent bg-surface-300 dark:bg-surface-500 opacity-70 bg-surface-300/70 dark:bg-surface-500/70": variant === "filled" && disabled,
|
|
67
68
|
});
|
|
68
69
|
|
|
69
70
|
const sizeClasses = cls(
|
package/src/components/Chip.tsx
CHANGED
|
@@ -29,7 +29,7 @@ const sizeClassNames = {
|
|
|
29
29
|
/**
|
|
30
30
|
* @group Preview components
|
|
31
31
|
*/
|
|
32
|
-
export function Chip({
|
|
32
|
+
export const Chip = React.forwardRef<HTMLDivElement, ChipProps>(function Chip({
|
|
33
33
|
children,
|
|
34
34
|
colorScheme,
|
|
35
35
|
error,
|
|
@@ -39,11 +39,12 @@ export function Chip({
|
|
|
39
39
|
size = "large",
|
|
40
40
|
className,
|
|
41
41
|
style
|
|
42
|
-
}: ChipProps) {
|
|
42
|
+
}: ChipProps, ref) {
|
|
43
43
|
|
|
44
44
|
const usedColorScheme = typeof colorScheme === "string" ? getColorSchemeForKey(colorScheme) : colorScheme;
|
|
45
45
|
return (
|
|
46
46
|
<div
|
|
47
|
+
ref={ref}
|
|
47
48
|
className={cls("rounded-lg max-w-full w-max h-fit font-regular inline-flex gap-1",
|
|
48
49
|
"text-ellipsis",
|
|
49
50
|
"items-center",
|
|
@@ -67,4 +68,4 @@ export function Chip({
|
|
|
67
68
|
{icon}
|
|
68
69
|
</div>
|
|
69
70
|
);
|
|
70
|
-
}
|
|
71
|
+
});
|
|
@@ -90,6 +90,7 @@ export const MultiSelect = React.forwardRef<
|
|
|
90
90
|
open,
|
|
91
91
|
onOpenChange,
|
|
92
92
|
portalContainer,
|
|
93
|
+
endAdornment,
|
|
93
94
|
},
|
|
94
95
|
ref
|
|
95
96
|
) => {
|
|
@@ -269,7 +270,7 @@ export const MultiSelect = React.forwardRef<
|
|
|
269
270
|
})}
|
|
270
271
|
</div>
|
|
271
272
|
<div className="flex items-center justify-between">
|
|
272
|
-
{includeClear && <CloseIcon
|
|
273
|
+
{includeClear && !endAdornment && <CloseIcon
|
|
273
274
|
className={"ml-4"}
|
|
274
275
|
size={"small"}
|
|
275
276
|
onClick={(event) => {
|
|
@@ -277,6 +278,14 @@ export const MultiSelect = React.forwardRef<
|
|
|
277
278
|
handleClear();
|
|
278
279
|
}}
|
|
279
280
|
/>}
|
|
281
|
+
{endAdornment && (
|
|
282
|
+
<div className="ml-4 flex items-center" onClick={(e) => {
|
|
283
|
+
e.preventDefault();
|
|
284
|
+
e.stopPropagation();
|
|
285
|
+
}}>
|
|
286
|
+
{endAdornment}
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
280
289
|
<div className={cls("px-2 h-full flex items-center")}>
|
|
281
290
|
<KeyboardArrowDownIcon size={size === "large" ? "medium" : "small"}
|
|
282
291
|
className={cls("transition", isPopoverOpen ? "rotate-180" : "")} />
|
|
@@ -288,9 +297,19 @@ export const MultiSelect = React.forwardRef<
|
|
|
288
297
|
<span className="text-sm">
|
|
289
298
|
{placeholder}
|
|
290
299
|
</span>
|
|
291
|
-
<div className=
|
|
292
|
-
|
|
293
|
-
className=
|
|
300
|
+
<div className="flex items-center justify-between">
|
|
301
|
+
{endAdornment && (
|
|
302
|
+
<div className="ml-4 flex items-center" onClick={(e) => {
|
|
303
|
+
e.preventDefault();
|
|
304
|
+
e.stopPropagation();
|
|
305
|
+
}}>
|
|
306
|
+
{endAdornment}
|
|
307
|
+
</div>
|
|
308
|
+
)}
|
|
309
|
+
<div className={cls("px-2 h-full flex items-center")}>
|
|
310
|
+
<KeyboardArrowDownIcon size={size === "large" ? "medium" : "small"}
|
|
311
|
+
className={cls("transition", isPopoverOpen ? "rotate-180" : "")} />
|
|
312
|
+
</div>
|
|
294
313
|
</div>
|
|
295
314
|
</div>
|
|
296
315
|
)}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { cls } from "../util";
|
|
3
|
+
|
|
4
|
+
export type ResizablePanelsProps = {
|
|
5
|
+
firstPanel: React.ReactNode;
|
|
6
|
+
secondPanel: React.ReactNode;
|
|
7
|
+
/** Whether the first panel is visible (e.g. Sidebar) */
|
|
8
|
+
showFirstPanel?: boolean;
|
|
9
|
+
/** Whether the second panel is visible (e.g. Results) */
|
|
10
|
+
showSecondPanel?: boolean;
|
|
11
|
+
/** 0-100 representing the width/height of the first panel */
|
|
12
|
+
panelSizePercent: number;
|
|
13
|
+
onPanelSizeChange: (sizePercent: number) => void;
|
|
14
|
+
minPanelSizePx?: number;
|
|
15
|
+
orientation?: 'horizontal' | 'vertical';
|
|
16
|
+
className?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function ResizablePanels({
|
|
20
|
+
firstPanel,
|
|
21
|
+
secondPanel,
|
|
22
|
+
showFirstPanel = true,
|
|
23
|
+
showSecondPanel = true,
|
|
24
|
+
panelSizePercent,
|
|
25
|
+
onPanelSizeChange,
|
|
26
|
+
minPanelSizePx = 200,
|
|
27
|
+
orientation = 'horizontal',
|
|
28
|
+
className
|
|
29
|
+
}: ResizablePanelsProps) {
|
|
30
|
+
|
|
31
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
32
|
+
const isResizingRef = useRef(false);
|
|
33
|
+
|
|
34
|
+
// For local layout tracking without triggering React rerenders during drag
|
|
35
|
+
const firstPanelRef = useRef<HTMLDivElement>(null);
|
|
36
|
+
const startPosRef = useRef(0);
|
|
37
|
+
const startSizeRef = useRef(0);
|
|
38
|
+
|
|
39
|
+
const [isResizing, setIsResizing] = useState(false);
|
|
40
|
+
const isHorizontal = orientation === 'horizontal';
|
|
41
|
+
|
|
42
|
+
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
|
43
|
+
if (!showFirstPanel || !showSecondPanel) return;
|
|
44
|
+
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
isResizingRef.current = true;
|
|
47
|
+
setIsResizing(true);
|
|
48
|
+
|
|
49
|
+
startPosRef.current = isHorizontal ? e.clientX : e.clientY;
|
|
50
|
+
|
|
51
|
+
if (firstPanelRef.current) {
|
|
52
|
+
const rect = firstPanelRef.current.getBoundingClientRect();
|
|
53
|
+
startSizeRef.current = isHorizontal ? rect.width : rect.height;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize';
|
|
57
|
+
document.body.style.userSelect = 'none';
|
|
58
|
+
}, [isHorizontal, showFirstPanel, showSecondPanel]);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
62
|
+
if (!isResizingRef.current || !containerRef.current) return;
|
|
63
|
+
|
|
64
|
+
const currentPos = isHorizontal ? e.clientX : e.clientY;
|
|
65
|
+
const delta = currentPos - startPosRef.current; // Dragging right/down increases first panel size
|
|
66
|
+
|
|
67
|
+
let newSize = startSizeRef.current + delta;
|
|
68
|
+
|
|
69
|
+
const containerRect = containerRef.current.getBoundingClientRect();
|
|
70
|
+
const containerTotal = isHorizontal ? containerRect.width : containerRect.height;
|
|
71
|
+
|
|
72
|
+
// Limit the maximum size to prevent pushing the second panel offscreen
|
|
73
|
+
const maxSize = containerTotal - minPanelSizePx;
|
|
74
|
+
|
|
75
|
+
newSize = Math.max(minPanelSizePx, Math.min(newSize, maxSize));
|
|
76
|
+
|
|
77
|
+
// Directly update the DOM for performance while dragging
|
|
78
|
+
if (firstPanelRef.current) {
|
|
79
|
+
if (isHorizontal) {
|
|
80
|
+
firstPanelRef.current.style.width = `${newSize}px`;
|
|
81
|
+
} else {
|
|
82
|
+
firstPanelRef.current.style.height = `${newSize}px`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const handleMouseUp = () => {
|
|
88
|
+
if (isResizingRef.current && containerRef.current && firstPanelRef.current) {
|
|
89
|
+
isResizingRef.current = false;
|
|
90
|
+
setIsResizing(false);
|
|
91
|
+
document.body.style.cursor = "";
|
|
92
|
+
document.body.style.userSelect = "";
|
|
93
|
+
|
|
94
|
+
// Calculate the final percentage and notify parent
|
|
95
|
+
const containerRect = containerRef.current.getBoundingClientRect();
|
|
96
|
+
const firstPanelRect = firstPanelRef.current.getBoundingClientRect();
|
|
97
|
+
|
|
98
|
+
const containerSize = isHorizontal ? containerRect.width : containerRect.height;
|
|
99
|
+
const finalSize = isHorizontal ? firstPanelRect.width : firstPanelRect.height;
|
|
100
|
+
|
|
101
|
+
if (containerSize > 0) {
|
|
102
|
+
const newPercent = (finalSize / containerSize) * 100;
|
|
103
|
+
onPanelSizeChange(Math.max(0, Math.min(100, newPercent)));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
window.addEventListener("mousemove", handleMouseMove);
|
|
109
|
+
window.addEventListener("mouseup", handleMouseUp);
|
|
110
|
+
|
|
111
|
+
return () => {
|
|
112
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
113
|
+
window.removeEventListener("mouseup", handleMouseUp);
|
|
114
|
+
};
|
|
115
|
+
}, [onPanelSizeChange, isHorizontal, minPanelSizePx]);
|
|
116
|
+
|
|
117
|
+
// Calculate applied size
|
|
118
|
+
const appliedBasis = !showFirstPanel ? "0%" : (showSecondPanel ? `${panelSizePercent}%` : "100%");
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
ref={containerRef}
|
|
123
|
+
className={cls(
|
|
124
|
+
"relative w-full h-full flex overflow-hidden",
|
|
125
|
+
isHorizontal ? "flex-row" : "flex-col",
|
|
126
|
+
className
|
|
127
|
+
)}
|
|
128
|
+
>
|
|
129
|
+
{/* First Panel */}
|
|
130
|
+
<div
|
|
131
|
+
ref={firstPanelRef}
|
|
132
|
+
className={cls(
|
|
133
|
+
"relative flex-shrink-0 flex flex-col overflow-hidden",
|
|
134
|
+
!showFirstPanel && "hidden"
|
|
135
|
+
)}
|
|
136
|
+
style={{
|
|
137
|
+
width: isHorizontal ? appliedBasis : "100%",
|
|
138
|
+
height: !isHorizontal ? appliedBasis : "100%",
|
|
139
|
+
minWidth: isHorizontal && showFirstPanel && showSecondPanel ? `${minPanelSizePx}px` : undefined,
|
|
140
|
+
minHeight: !isHorizontal && showFirstPanel && showSecondPanel ? `${minPanelSizePx}px` : undefined,
|
|
141
|
+
maxWidth: showSecondPanel ? undefined : "100%",
|
|
142
|
+
maxHeight: showSecondPanel ? undefined : "100%",
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
{firstPanel}
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{/* Divider */}
|
|
149
|
+
{showFirstPanel && showSecondPanel && (
|
|
150
|
+
<div
|
|
151
|
+
className={cls(
|
|
152
|
+
"relative z-10 flex flex-shrink-0 items-center justify-center",
|
|
153
|
+
isHorizontal ? "w-px h-full cursor-col-resize" : "h-px w-full cursor-row-resize"
|
|
154
|
+
)}
|
|
155
|
+
onMouseDown={handleResizeStart}
|
|
156
|
+
>
|
|
157
|
+
{/* Transparent Hit Area with Pill inside */}
|
|
158
|
+
<div className={cls(
|
|
159
|
+
"absolute flex items-center justify-center group",
|
|
160
|
+
isHorizontal ? "w-4 h-full cursor-col-resize top-0" : "h-4 w-full cursor-row-resize left-0"
|
|
161
|
+
)}>
|
|
162
|
+
<div className={cls(
|
|
163
|
+
"bg-primary/60 dark:bg-primary rounded-full opacity-0 group-hover:opacity-100 transition-all duration-200",
|
|
164
|
+
isHorizontal ? "w-1 h-8" : "h-1 w-8"
|
|
165
|
+
)} />
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
{/* Second Panel */}
|
|
171
|
+
<div
|
|
172
|
+
className={cls(
|
|
173
|
+
"flex-grow relative flex flex-col overflow-hidden min-w-0 min-h-0",
|
|
174
|
+
!showSecondPanel && "hidden"
|
|
175
|
+
)}
|
|
176
|
+
>
|
|
177
|
+
{secondPanel}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { Command as CommandPrimitive } from "cmdk";
|
|
6
|
+
import { cls } from "../util";
|
|
7
|
+
import { CheckIcon, KeyboardArrowDownIcon } from "../icons";
|
|
8
|
+
import { Separator } from "./Separator";
|
|
9
|
+
import {
|
|
10
|
+
defaultBorderMixin,
|
|
11
|
+
fieldBackgroundDisabledMixin,
|
|
12
|
+
fieldBackgroundHoverMixin,
|
|
13
|
+
fieldBackgroundInvisibleMixin,
|
|
14
|
+
fieldBackgroundMixin,
|
|
15
|
+
focusedDisabled
|
|
16
|
+
} from "../styles";
|
|
17
|
+
import { useInjectStyles } from "../hooks";
|
|
18
|
+
import { usePortalContainer } from "../hooks/PortalContainerContext";
|
|
19
|
+
|
|
20
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface SearchableSelectProps {
|
|
23
|
+
/** Currently selected value. Can be one of the items or a custom string. */
|
|
24
|
+
value?: string;
|
|
25
|
+
/** Callback when the value changes (from selection or custom input). */
|
|
26
|
+
onValueChange?: (value: string) => void;
|
|
27
|
+
/** Placeholder shown when no value is selected. */
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
/** Label above the field. */
|
|
30
|
+
label?: React.ReactNode | string;
|
|
31
|
+
/** Size variant. */
|
|
32
|
+
size?: "smallest" | "small" | "medium" | "large";
|
|
33
|
+
/** Whether the field is disabled. */
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
/** Whether to show an error state. */
|
|
36
|
+
error?: boolean;
|
|
37
|
+
/** Whether to use the invisible (borderless) style. */
|
|
38
|
+
invisible?: boolean;
|
|
39
|
+
/** CSS class for the trigger button. */
|
|
40
|
+
className?: string;
|
|
41
|
+
/** CSS class for the trigger input area. */
|
|
42
|
+
inputClassName?: string;
|
|
43
|
+
/** Render the selected value in a custom way in the trigger. */
|
|
44
|
+
renderValue?: (value: string) => React.ReactNode;
|
|
45
|
+
/** Whether the popover should trap focus. */
|
|
46
|
+
modalPopover?: boolean;
|
|
47
|
+
/** If true, allow accepting the typed text as the value even if it doesn't match an item. */
|
|
48
|
+
allowCustomValues?: boolean;
|
|
49
|
+
/** Portal container element. */
|
|
50
|
+
portalContainer?: HTMLElement | null;
|
|
51
|
+
/** If true, auto-open the popover on mount so the user can start typing immediately. */
|
|
52
|
+
autoFocus?: boolean;
|
|
53
|
+
/** The option items — use SearchableSelectItem. */
|
|
54
|
+
children: React.ReactNode;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SearchableSelectItemProps {
|
|
58
|
+
value: string;
|
|
59
|
+
children?: React.ReactNode;
|
|
60
|
+
className?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Component ──────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export const SearchableSelect = React.forwardRef<
|
|
66
|
+
HTMLButtonElement,
|
|
67
|
+
SearchableSelectProps
|
|
68
|
+
>(
|
|
69
|
+
(
|
|
70
|
+
{
|
|
71
|
+
value,
|
|
72
|
+
onValueChange,
|
|
73
|
+
placeholder = "Select...",
|
|
74
|
+
label,
|
|
75
|
+
size = "large",
|
|
76
|
+
disabled,
|
|
77
|
+
error,
|
|
78
|
+
invisible,
|
|
79
|
+
className,
|
|
80
|
+
inputClassName,
|
|
81
|
+
renderValue,
|
|
82
|
+
modalPopover = false,
|
|
83
|
+
allowCustomValues = true,
|
|
84
|
+
portalContainer,
|
|
85
|
+
autoFocus,
|
|
86
|
+
children,
|
|
87
|
+
},
|
|
88
|
+
ref
|
|
89
|
+
) => {
|
|
90
|
+
const [isMounted, setIsMounted] = useState(false);
|
|
91
|
+
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
92
|
+
const [search, setSearch] = useState("");
|
|
93
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
94
|
+
|
|
95
|
+
const contextContainer = usePortalContainer();
|
|
96
|
+
const finalContainer = (portalContainer ?? contextContainer ?? undefined) as HTMLElement | undefined;
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
setIsMounted(true);
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
// Auto-open popover on mount when autoFocus is true
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (autoFocus && isMounted) {
|
|
105
|
+
onPopoverOpenChange(true);
|
|
106
|
+
}
|
|
107
|
+
}, [autoFocus, isMounted]);
|
|
108
|
+
|
|
109
|
+
// Collect all item values + labels from children
|
|
110
|
+
const itemsMap = React.useMemo(() => {
|
|
111
|
+
const map = new Map<string, React.ReactNode>();
|
|
112
|
+
React.Children.forEach(children, (child) => {
|
|
113
|
+
if (React.isValidElement<SearchableSelectItemProps>(child) && child.props.value != null) {
|
|
114
|
+
map.set(String(child.props.value), child.props.children ?? child.props.value);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
return map;
|
|
118
|
+
}, [children]);
|
|
119
|
+
|
|
120
|
+
const onPopoverOpenChange = (open: boolean) => {
|
|
121
|
+
setIsPopoverOpen(open);
|
|
122
|
+
if (open) {
|
|
123
|
+
// Pre-fill search with current value for easy editing
|
|
124
|
+
setSearch(value ?? "");
|
|
125
|
+
// Focus the input after popover opens
|
|
126
|
+
setTimeout(() => inputRef.current?.focus(), 0);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleSelect = (selectedValue: string) => {
|
|
131
|
+
onValueChange?.(selectedValue);
|
|
132
|
+
setIsPopoverOpen(false);
|
|
133
|
+
setSearch("");
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
137
|
+
if (e.key === "Enter" && allowCustomValues) {
|
|
138
|
+
const trimmed = search.trim();
|
|
139
|
+
if (trimmed) {
|
|
140
|
+
// If cmdk found no match, accept custom value
|
|
141
|
+
// If there are matches, cmdk will handle selecting the highlighted one
|
|
142
|
+
// We check if the current search is NOT one of the items
|
|
143
|
+
const isExistingItem = itemsMap.has(trimmed);
|
|
144
|
+
if (!isExistingItem) {
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
handleSelect(trimmed);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} else if (e.key === "Escape") {
|
|
150
|
+
setIsPopoverOpen(false);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Resolve display label for the trigger
|
|
155
|
+
const displayLabel = React.useMemo(() => {
|
|
156
|
+
if (!value) return null;
|
|
157
|
+
if (renderValue) return renderValue(value);
|
|
158
|
+
const itemLabel = itemsMap.get(value);
|
|
159
|
+
if (itemLabel) return itemLabel;
|
|
160
|
+
return <span className="text-sm">{value}</span>;
|
|
161
|
+
}, [value, renderValue, itemsMap]);
|
|
162
|
+
|
|
163
|
+
useInjectStyles("SearchableSelect", `
|
|
164
|
+
[cmdk-group] {
|
|
165
|
+
max-height: 45vh;
|
|
166
|
+
overflow-y: auto;
|
|
167
|
+
}`);
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div>
|
|
171
|
+
{label && (
|
|
172
|
+
typeof label === "string"
|
|
173
|
+
? <div className={cls("text-sm font-medium ml-3.5 mb-1",
|
|
174
|
+
error ? "text-red-500 dark:text-red-600" : "text-surface-accent-500 dark:text-surface-accent-300",
|
|
175
|
+
)}>{label}</div>
|
|
176
|
+
: label
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
<PopoverPrimitive.Root
|
|
180
|
+
open={isMounted && isPopoverOpen}
|
|
181
|
+
onOpenChange={onPopoverOpenChange}
|
|
182
|
+
modal={modalPopover}
|
|
183
|
+
>
|
|
184
|
+
<PopoverPrimitive.Trigger asChild>
|
|
185
|
+
<button
|
|
186
|
+
ref={ref}
|
|
187
|
+
disabled={disabled}
|
|
188
|
+
onClick={() => !disabled && onPopoverOpenChange(!isPopoverOpen)}
|
|
189
|
+
className={cls(
|
|
190
|
+
{
|
|
191
|
+
"min-h-[28px]": size === "smallest",
|
|
192
|
+
"min-h-[32px]": size === "small",
|
|
193
|
+
"min-h-[44px]": size === "medium",
|
|
194
|
+
"min-h-[64px]": size === "large",
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
"py-0.5": size === "smallest",
|
|
198
|
+
"py-1": size === "small",
|
|
199
|
+
"py-2": size === "medium" || size === "large",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"px-2": size === "small" || size === "smallest",
|
|
203
|
+
"px-4": size === "medium" || size === "large",
|
|
204
|
+
},
|
|
205
|
+
"select-none rounded-md text-sm w-full text-start",
|
|
206
|
+
"focus:ring-0 focus-visible:ring-0 outline-none focus:outline-none focus-visible:outline-none",
|
|
207
|
+
invisible ? fieldBackgroundInvisibleMixin : fieldBackgroundMixin,
|
|
208
|
+
disabled ? fieldBackgroundDisabledMixin : fieldBackgroundHoverMixin,
|
|
209
|
+
"relative flex items-center",
|
|
210
|
+
className,
|
|
211
|
+
inputClassName
|
|
212
|
+
)}
|
|
213
|
+
>
|
|
214
|
+
<div className="flex items-center justify-between w-full gap-1">
|
|
215
|
+
<div className="flex-grow min-w-0 truncate">
|
|
216
|
+
{displayLabel ?? (
|
|
217
|
+
<span className="text-sm text-surface-accent-500 dark:text-surface-accent-400">
|
|
218
|
+
{placeholder}
|
|
219
|
+
</span>
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
<div className={cls("flex-shrink-0 flex items-center")}>
|
|
223
|
+
<KeyboardArrowDownIcon
|
|
224
|
+
size={size === "large" ? "medium" : "small"}
|
|
225
|
+
className={cls("transition", isPopoverOpen ? "rotate-180" : "")}
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</button>
|
|
230
|
+
</PopoverPrimitive.Trigger>
|
|
231
|
+
<PopoverPrimitive.Portal container={finalContainer}>
|
|
232
|
+
<PopoverPrimitive.Content
|
|
233
|
+
className={cls(
|
|
234
|
+
"z-50 overflow-hidden border bg-white dark:bg-surface-900 rounded-lg",
|
|
235
|
+
defaultBorderMixin
|
|
236
|
+
)}
|
|
237
|
+
align="start"
|
|
238
|
+
sideOffset={4}
|
|
239
|
+
style={{ width: "var(--radix-popover-trigger-width)", minWidth: 180 }}
|
|
240
|
+
onEscapeKeyDown={() => onPopoverOpenChange(false)}
|
|
241
|
+
>
|
|
242
|
+
<CommandPrimitive shouldFilter={true}>
|
|
243
|
+
<div className="flex flex-row items-center">
|
|
244
|
+
<CommandPrimitive.Input
|
|
245
|
+
ref={inputRef}
|
|
246
|
+
value={search}
|
|
247
|
+
onValueChange={setSearch}
|
|
248
|
+
className={cls(
|
|
249
|
+
focusedDisabled,
|
|
250
|
+
"bg-transparent outline-none flex-1 h-full w-full text-surface-accent-900 dark:text-white",
|
|
251
|
+
{
|
|
252
|
+
"m-2 text-xs": size === "smallest" || size === "small",
|
|
253
|
+
"m-3 text-sm": size === "medium" || size === "large",
|
|
254
|
+
},
|
|
255
|
+
)}
|
|
256
|
+
placeholder="Search..."
|
|
257
|
+
onKeyDown={handleInputKeyDown}
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
<Separator orientation="horizontal" className="my-0" />
|
|
261
|
+
<CommandPrimitive.List>
|
|
262
|
+
{allowCustomValues && search.trim() && !itemsMap.has(search.trim()) ? (
|
|
263
|
+
<CommandPrimitive.Empty
|
|
264
|
+
className="px-3 py-2 text-xs cursor-pointer text-primary hover:bg-surface-accent-100 dark:hover:bg-surface-accent-800"
|
|
265
|
+
onClick={() => handleSelect(search.trim())}
|
|
266
|
+
>
|
|
267
|
+
Use “{search.trim()}”
|
|
268
|
+
</CommandPrimitive.Empty>
|
|
269
|
+
) : (
|
|
270
|
+
<CommandPrimitive.Empty className="px-3 py-2 text-xs text-text-secondary dark:text-text-secondary-dark">
|
|
271
|
+
No results found.
|
|
272
|
+
</CommandPrimitive.Empty>
|
|
273
|
+
)}
|
|
274
|
+
<CommandPrimitive.Group>
|
|
275
|
+
{React.Children.map(children, (child) => {
|
|
276
|
+
if (!React.isValidElement<SearchableSelectItemProps>(child)) return child;
|
|
277
|
+
const itemValue = child.props.value;
|
|
278
|
+
const isSelected = String(value) === String(itemValue);
|
|
279
|
+
return (
|
|
280
|
+
<CommandPrimitive.Item
|
|
281
|
+
key={String(itemValue)}
|
|
282
|
+
value={String(itemValue)}
|
|
283
|
+
onMouseDown={(e) => {
|
|
284
|
+
e.preventDefault();
|
|
285
|
+
e.stopPropagation();
|
|
286
|
+
}}
|
|
287
|
+
onSelect={() => handleSelect(String(itemValue))}
|
|
288
|
+
className={cls(
|
|
289
|
+
"flex flex-row items-center gap-1.5",
|
|
290
|
+
isSelected ? "bg-surface-accent-200 dark:bg-surface-accent-950" : "",
|
|
291
|
+
"cursor-pointer",
|
|
292
|
+
"m-0.5",
|
|
293
|
+
"ring-offset-transparent",
|
|
294
|
+
"p-1.5 rounded",
|
|
295
|
+
"aria-[selected=true]:outline-none",
|
|
296
|
+
"aria-[selected=true]:bg-surface-accent-100 aria-[selected=true]:dark:bg-surface-accent-900",
|
|
297
|
+
"text-surface-accent-700 dark:text-surface-accent-300",
|
|
298
|
+
child.props.className
|
|
299
|
+
)}
|
|
300
|
+
>
|
|
301
|
+
<div className={cls(
|
|
302
|
+
"w-4 h-4 flex items-center justify-center flex-shrink-0",
|
|
303
|
+
isSelected ? "text-primary" : "text-transparent",
|
|
304
|
+
)}>
|
|
305
|
+
{isSelected && <CheckIcon size={14} />}
|
|
306
|
+
</div>
|
|
307
|
+
{child.props.children ?? child.props.value}
|
|
308
|
+
</CommandPrimitive.Item>
|
|
309
|
+
);
|
|
310
|
+
})}
|
|
311
|
+
</CommandPrimitive.Group>
|
|
312
|
+
</CommandPrimitive.List>
|
|
313
|
+
</CommandPrimitive>
|
|
314
|
+
</PopoverPrimitive.Content>
|
|
315
|
+
</PopoverPrimitive.Portal>
|
|
316
|
+
</PopoverPrimitive.Root>
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
SearchableSelect.displayName = "SearchableSelect";
|
|
323
|
+
|
|
324
|
+
// ─── Item ───────────────────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* A single option inside a SearchableSelect.
|
|
328
|
+
* The `value` prop is the string value that gets selected.
|
|
329
|
+
* The `children` is what's displayed in the dropdown.
|
|
330
|
+
* This component is not rendered directly — SearchableSelect reads its props.
|
|
331
|
+
*/
|
|
332
|
+
export function SearchableSelectItem(_props: SearchableSelectItemProps) {
|
|
333
|
+
// Rendered by SearchableSelect, not by React
|
|
334
|
+
return null;
|
|
335
|
+
}
|