@firecms/ui 3.1.0-canary.1df3b2c → 3.1.0-canary.501d471

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.
Files changed (36) hide show
  1. package/dist/components/BooleanSwitchWithLabel.d.ts +2 -1
  2. package/dist/components/Chip.d.ts +1 -1
  3. package/dist/components/MultiSelect.d.ts +1 -1
  4. package/dist/components/ResizablePanels.d.ts +16 -0
  5. package/dist/components/SearchableSelect.d.ts +48 -0
  6. package/dist/components/Select.d.ts +1 -1
  7. package/dist/components/Tabs.d.ts +8 -1
  8. package/dist/components/Tooltip.d.ts +18 -2
  9. package/dist/components/index.d.ts +2 -0
  10. package/dist/hooks/useOutsideAlerter.d.ts +1 -1
  11. package/dist/icons/FirestoreIcon.d.ts +6 -0
  12. package/dist/icons/components/DatabaseIcon.d.ts +6 -0
  13. package/dist/icons/index.d.ts +2 -0
  14. package/dist/index.es.js +1444 -431
  15. package/dist/index.es.js.map +1 -1
  16. package/dist/index.umd.js +1446 -433
  17. package/dist/index.umd.js.map +1 -1
  18. package/package.json +6 -6
  19. package/src/components/BooleanSwitchWithLabel.tsx +4 -0
  20. package/src/components/Button.tsx +2 -1
  21. package/src/components/Chip.tsx +4 -3
  22. package/src/components/DateTimeField.tsx +7 -2
  23. package/src/components/DebouncedTextField.tsx +3 -3
  24. package/src/components/MultiSelect.tsx +27 -10
  25. package/src/components/ResizablePanels.tsx +181 -0
  26. package/src/components/SearchableSelect.tsx +335 -0
  27. package/src/components/Select.tsx +62 -62
  28. package/src/components/Skeleton.tsx +4 -2
  29. package/src/components/Tabs.tsx +150 -34
  30. package/src/components/TextareaAutosize.tsx +77 -212
  31. package/src/components/Tooltip.tsx +7 -6
  32. package/src/components/index.tsx +2 -0
  33. package/src/hooks/useOutsideAlerter.tsx +1 -1
  34. package/src/icons/FirestoreIcon.tsx +47 -0
  35. package/src/icons/components/DatabaseIcon.tsx +10 -0
  36. package/src/icons/index.ts +2 -0
@@ -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 &ldquo;{search.trim()}&rdquo;
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
+ }
@@ -36,7 +36,7 @@ export type SelectProps<T extends SelectValue = string> = {
36
36
  error?: boolean,
37
37
  position?: "item-aligned" | "popper",
38
38
  endAdornment?: React.ReactNode,
39
- inputRef?: React.RefObject<HTMLButtonElement>,
39
+ inputRef?: React.RefObject<HTMLButtonElement | null>,
40
40
  padding?: boolean,
41
41
  invisible?: boolean,
42
42
  children?: React.ReactNode;
@@ -45,33 +45,33 @@ export type SelectProps<T extends SelectValue = string> = {
45
45
  };
46
46
 
47
47
  export const Select = forwardRef<HTMLDivElement, SelectProps>(({
48
- inputRef,
49
- open,
50
- name,
51
- fullWidth = false,
52
- id,
53
- onOpenChange,
54
- value,
55
- onChange,
56
- onValueChange,
57
- className,
58
- inputClassName,
59
- viewportClassName,
60
- placeholder,
61
- renderValue,
62
- label,
63
- size = "large",
64
- error,
65
- disabled,
66
- padding = true,
67
- position = "item-aligned",
68
- endAdornment,
69
- invisible,
70
- children,
71
- dataType = "string",
72
- portalContainer: manualContainer, // Rename to avoid confusion
73
- ...props
74
- }, ref) => {
48
+ inputRef,
49
+ open,
50
+ name,
51
+ fullWidth = false,
52
+ id,
53
+ onOpenChange,
54
+ value,
55
+ onChange,
56
+ onValueChange,
57
+ className,
58
+ inputClassName,
59
+ viewportClassName,
60
+ placeholder,
61
+ renderValue,
62
+ label,
63
+ size = "large",
64
+ error,
65
+ disabled,
66
+ padding = true,
67
+ position = "item-aligned",
68
+ endAdornment,
69
+ invisible,
70
+ children,
71
+ dataType = "string",
72
+ portalContainer: manualContainer, // Rename to avoid confusion
73
+ ...props
74
+ }, ref) => {
75
75
 
76
76
  const [openInternal, setOpenInternal] = useState(open ?? false);
77
77
 
@@ -115,7 +115,7 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(({
115
115
  // Find the child that matches the current value to display its content
116
116
  let found: React.ReactNode = null;
117
117
  Children.forEach(children, (child) => {
118
- if (React.isValidElement(child) && String((child.props as any).value) === String(value)) {
118
+ if (React.isValidElement<SelectItemProps>(child) && String(child.props.value) === String(value)) {
119
119
  found = child.props.children;
120
120
  }
121
121
  });
@@ -192,30 +192,30 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(({
192
192
  "min-h-[64px]": size === "large"
193
193
  }
194
194
  )}>
195
- <SelectPrimitive.Value
196
- onClick={(e) => {
197
- e.preventDefault();
198
- e.stopPropagation();
199
- }}
200
- placeholder={placeholder}
201
- className={"w-full"}>
195
+ <SelectPrimitive.Value
196
+ onClick={(e) => {
197
+ e.preventDefault();
198
+ e.stopPropagation();
199
+ }}
200
+ placeholder={placeholder}
201
+ className={"w-full"}>
202
202
 
203
- {hasValue && value !== undefined && renderValue
204
- ? renderValue(value)
205
- : (displayChildren || placeholder)
206
- }
203
+ {hasValue && value !== undefined && renderValue
204
+ ? renderValue(value)
205
+ : (displayChildren || placeholder)
206
+ }
207
207
 
208
- </SelectPrimitive.Value>
209
- </div>
208
+ </SelectPrimitive.Value>
209
+ </div>
210
210
 
211
- <SelectPrimitive.Icon asChild>
212
- <KeyboardArrowDownIcon size={size === "large" ? "medium" : "small"}
213
- className={cls("transition", open ? "rotate-180" : "", {
214
- "px-2": size === "large",
215
- "px-1": size === "medium" || size === "small",
216
- })}/>
217
- </SelectPrimitive.Icon>
218
- </SelectPrimitive.Trigger>
211
+ <SelectPrimitive.Icon asChild>
212
+ <KeyboardArrowDownIcon size={size === "large" ? "medium" : "small"}
213
+ className={cls("transition", open ? "rotate-180" : "", {
214
+ "px-2": size === "large",
215
+ "px-1": size === "medium" || size === "small",
216
+ })} />
217
+ </SelectPrimitive.Icon>
218
+ </SelectPrimitive.Trigger>
219
219
 
220
220
  {endAdornment && (
221
221
  <div
@@ -232,9 +232,9 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(({
232
232
  {/* Pass the calculated finalContainer */}
233
233
  <SelectPrimitive.Portal container={finalContainer}>
234
234
  <SelectPrimitive.Content position={position}
235
- className={cls(focusedDisabled, "z-50 relative overflow-hidden border bg-white dark:bg-surface-900 p-2 rounded-lg", defaultBorderMixin)}>
235
+ className={cls(focusedDisabled, "z-50 relative overflow-hidden border bg-white dark:bg-surface-900 p-2 rounded-lg", defaultBorderMixin)}>
236
236
  <SelectPrimitive.Viewport className={cls("p-1", viewportClassName)}
237
- style={{ maxHeight: "var(--radix-select-content-available-height)" }}>
237
+ style={{ maxHeight: "var(--radix-select-content-available-height)" }}>
238
238
  {children}
239
239
  </SelectPrimitive.Viewport>
240
240
  </SelectPrimitive.Content>
@@ -254,11 +254,11 @@ export type SelectItemProps<T extends SelectValue = string> = {
254
254
  };
255
255
 
256
256
  export const SelectItem = React.memo(function SelectItem<T extends SelectValue = string>({
257
- value,
258
- children,
259
- disabled,
260
- className
261
- }: SelectItemProps<T>) {
257
+ value,
258
+ children,
259
+ disabled,
260
+ className
261
+ }: SelectItemProps<T>) {
262
262
  // Convert value to string for Radix UI
263
263
  const stringValue = String(value);
264
264
 
@@ -280,7 +280,7 @@ export const SelectItem = React.memo(function SelectItem<T extends SelectValue =
280
280
  <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
281
281
  <div
282
282
  className="absolute left-1 data-[state=checked]:block hidden">
283
- <CheckIcon size={16}/>
283
+ <CheckIcon size={16} />
284
284
  </div>
285
285
  </SelectPrimitive.Item>;
286
286
  });
@@ -292,10 +292,10 @@ export type SelectGroupProps = {
292
292
  };
293
293
 
294
294
  export const SelectGroup = React.memo(function SelectGroup({
295
- label,
296
- children,
297
- className
298
- }: SelectGroupProps) {
295
+ label,
296
+ children,
297
+ className
298
+ }: SelectGroupProps) {
299
299
  return <>
300
300
  <SelectPrimitive.Group
301
301
  className={cls(
@@ -14,8 +14,8 @@ export function Skeleton({
14
14
  }: SkeletonProps) {
15
15
  return <span
16
16
  style={{
17
- width: width ? `${width}px` : "100%",
18
- height: height ? `${height}px` : "12px"
17
+ width: width !== undefined ? `${width}px` : undefined,
18
+ height: height !== undefined ? `${height}px` : undefined
19
19
  }}
20
20
  className={
21
21
  cls(
@@ -23,6 +23,8 @@ export function Skeleton({
23
23
  "bg-surface-accent-200 dark:bg-surface-accent-800 rounded-md",
24
24
  "animate-pulse",
25
25
  "max-w-full max-h-full",
26
+ width === undefined ? "w-full" : "",
27
+ height === undefined ? "h-3" : "",
26
28
  className)
27
29
  }/>;
28
30
  }