@firecms/ui 3.0.1 → 3.1.0-canary.24c8270

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 (59) hide show
  1. package/README.md +9 -7
  2. package/dist/components/Card.d.ts +1 -1
  3. package/dist/components/ColorPicker.d.ts +30 -0
  4. package/dist/components/DateTimeField.d.ts +7 -0
  5. package/dist/components/Dialog.d.ts +2 -1
  6. package/dist/components/FileUpload.d.ts +1 -1
  7. package/dist/components/Menu.d.ts +2 -1
  8. package/dist/components/Menubar.d.ts +2 -1
  9. package/dist/components/MultiSelect.d.ts +2 -1
  10. package/dist/components/SearchBar.d.ts +11 -1
  11. package/dist/components/Select.d.ts +2 -1
  12. package/dist/components/Sheet.d.ts +1 -0
  13. package/dist/components/ToggleButtonGroup.d.ts +30 -0
  14. package/dist/components/index.d.ts +2 -0
  15. package/dist/hooks/PortalContainerContext.d.ts +31 -0
  16. package/dist/hooks/index.d.ts +1 -0
  17. package/dist/hooks/useOutsideAlerter.d.ts +1 -1
  18. package/dist/index.css +57 -6
  19. package/dist/index.es.js +1731 -949
  20. package/dist/index.es.js.map +1 -1
  21. package/dist/index.umd.js +1731 -949
  22. package/dist/index.umd.js.map +1 -1
  23. package/dist/styles.d.ts +11 -11
  24. package/package.json +7 -7
  25. package/src/components/BooleanSwitch.tsx +3 -3
  26. package/src/components/Button.tsx +5 -5
  27. package/src/components/Card.tsx +7 -7
  28. package/src/components/Checkbox.tsx +1 -1
  29. package/src/components/ColorPicker.tsx +134 -0
  30. package/src/components/DateTimeField.tsx +123 -34
  31. package/src/components/DebouncedTextField.tsx +3 -3
  32. package/src/components/Dialog.tsx +25 -16
  33. package/src/components/DialogActions.tsx +1 -1
  34. package/src/components/ExpandablePanel.tsx +1 -1
  35. package/src/components/FileUpload.tsx +25 -24
  36. package/src/components/IconButton.tsx +3 -2
  37. package/src/components/Menu.tsx +44 -30
  38. package/src/components/Menubar.tsx +14 -3
  39. package/src/components/MultiSelect.tsx +91 -74
  40. package/src/components/Popover.tsx +11 -3
  41. package/src/components/SearchBar.tsx +37 -19
  42. package/src/components/Select.tsx +86 -73
  43. package/src/components/Separator.tsx +2 -2
  44. package/src/components/Sheet.tsx +12 -3
  45. package/src/components/Slider.tsx +4 -4
  46. package/src/components/Table.tsx +1 -1
  47. package/src/components/Tabs.tsx +121 -36
  48. package/src/components/TextField.tsx +19 -8
  49. package/src/components/ToggleButtonGroup.tsx +67 -0
  50. package/src/components/Tooltip.tsx +9 -2
  51. package/src/components/index.tsx +2 -0
  52. package/src/hooks/PortalContainerContext.tsx +48 -0
  53. package/src/hooks/index.ts +1 -0
  54. package/src/hooks/useInjectStyles.tsx +12 -3
  55. package/src/hooks/useOutsideAlerter.tsx +1 -1
  56. package/src/index.css +57 -6
  57. package/src/styles.ts +11 -11
  58. package/src/util/cls.ts +1 -1
  59. package/tailwind.config.js +2 -3
package/dist/styles.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  export declare const focusedDisabled = "focus-visible:ring-0 focus-visible:ring-offset-0";
2
- export declare const focusedInvisibleMixin = "focus:bg-opacity-70 focus:bg-surface-accent-100 focus:dark:bg-surface-800 focus:dark:bg-opacity-60";
3
- export declare const focusedClasses = "z-30 outline-none ring-2 ring-primary ring-opacity-75 ring-offset-2 ring-offset-transparent ";
4
- export declare const fieldBackgroundMixin = "bg-opacity-50 bg-surface-accent-200 dark:bg-surface-800 dark:bg-opacity-60";
5
- export declare const fieldBackgroundInvisibleMixin = "bg-opacity-0 bg-surface-accent-100 dark:bg-surface-800 dark:bg-opacity-0";
6
- export declare const fieldBackgroundDisabledMixin = "dark:bg-surface-800 bg-opacity-50 dark:bg-opacity-90";
7
- export declare const fieldBackgroundHoverMixin = "hover:bg-opacity-70 dark:hover:bg-surface-700 dark:hover:bg-opacity-40";
8
- export declare const defaultBorderMixin = "border-surface-200 border-opacity-40 dark:border-surface-700 dark:border-opacity-40";
9
- export declare const paperMixin = "bg-white rounded-md dark:bg-surface-950 border border-surface-200 border-opacity-40 dark:border-surface-700 dark:border-opacity-40";
10
- export declare const cardMixin = "bg-white border border-surface-200 border-opacity-40 dark:border-transparent rounded-md dark:bg-surface-950 dark:border-surface-700 dark:border-opacity-40";
11
- export declare const cardClickableMixin = "hover:bg-surface-accent-100 dark:hover:bg-surface-accent-800 hover:ring-2 hover:ring-primary cursor-pointer";
12
- export declare const cardSelectedMixin = "bg-primary-bg dark:bg-primary-bg bg-opacity-30 dark:bg-opacity-10 ring-1 ring-primary ring-opacity-75";
2
+ export declare const focusedInvisibleMixin = "focus:bg-opacity-70 focus:bg-surface-accent-100 focus:dark:bg-surface-800 focus:dark:bg-opacity-60 focus:bg-surface-accent-100/70 dark:focus:bg-surface-800/60";
3
+ export declare const focusedClasses = "z-30 outline-hidden outline-none ring-2 ring-primary ring-opacity-75 ring-primary/75 ring-offset-2 ring-offset-transparent ";
4
+ export declare const fieldBackgroundMixin = "bg-opacity-50 bg-surface-accent-200 bg-surface-accent-200/50 dark:bg-surface-800 dark:bg-opacity-60 dark:bg-surface-800/60";
5
+ export declare const fieldBackgroundInvisibleMixin = "bg-opacity-0 bg-surface-accent-100 dark:bg-surface-800 dark:bg-opacity-0 bg-surface-accent-200/0 dark:bg-surface-800/0";
6
+ export declare const fieldBackgroundDisabledMixin = "dark:bg-surface-800 bg-opacity-50 dark:bg-opacity-90 bg-surface-accent-200/50 dark:bg-surface-800/90";
7
+ export declare const fieldBackgroundHoverMixin = "hover:bg-opacity-70 dark:hover:bg-surface-700 dark:hover:bg-opacity-40 hover:bg-surface-accent-200/70 hover:dark:bg-surface-700/40";
8
+ export declare const defaultBorderMixin = "border-surface-200 border-opacity-40 dark:border-surface-700 dark:border-opacity-40 border-surface-200/40 dark:border-surface-700/40 ";
9
+ export declare const paperMixin = "bg-white rounded-md dark:bg-surface-950 border border-surface-200 border-opacity-40 dark:border-surface-700 dark:border-opacity-40 border-surface-200/40 dark:border-surface-700/40";
10
+ export declare const cardMixin = "bg-white dark:bg-surface-950 rounded-md border border-surface-200/40 dark:border-surface-700/40 m-1 -p-1";
11
+ export declare const cardClickableMixin = "hover:bg-surface-accent-100 dark:hover:bg-surface-accent-800 hover:ring-2 hover:ring-primary cursor-pointer hover:bg-primary/20 dark:hover:bg-primary/10 ";
12
+ export declare const cardSelectedMixin = "bg-primary-bg dark:bg-primary-bg bg-opacity-30 bg-primary-bg/30 dark:bg-opacity-10 dark:bg-primary-bg/10 ring-1 ring-primary ring-opacity-75 ring-primary/75 bg-primary/10 dark:bg-primary/10 ring-1 ring-primary/75";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@firecms/ui",
3
3
  "type": "module",
4
- "version": "3.0.1",
4
+ "version": "3.1.0-canary.24c8270",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/firecmsco"
@@ -48,7 +48,7 @@
48
48
  "prepublishOnly": "run-s build",
49
49
  "createTag": "PACKAGE_VERSION=$(cat package.json | grep version | head -1 | awk -F: '{ print $2 }' | sed 's/[\",]//g' | tr -d '[[:space:]]') && git tag v$PACKAGE_VERSION && git push --tags",
50
50
  "test:lint": "eslint \"src/**\" --quiet",
51
- "test": "jest",
51
+ "test": "jest --passWithNoTests",
52
52
  "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f",
53
53
  "generateIcons": "bun --esm src/scripts/generateIcons.ts"
54
54
  },
@@ -80,8 +80,8 @@
80
80
  "tailwind-merge": "^2.6.0"
81
81
  },
82
82
  "peerDependencies": {
83
- "react": ">=18.0.0",
84
- "react-dom": ">=18.0.0"
83
+ "react": ">=18.3.1 || >=19.0.0",
84
+ "react-dom": ">=18.3.1 || >=19.0.0"
85
85
  },
86
86
  "devDependencies": {
87
87
  "@jest/globals": "^30.2.0",
@@ -91,8 +91,8 @@
91
91
  "@types/jest": "^29.5.14",
92
92
  "@types/node": "^20.19.17",
93
93
  "@types/object-hash": "^3.0.6",
94
- "@types/react": "^18.3.24",
95
- "@types/react-dom": "^18.3.7",
94
+ "@types/react": "^19.2.3",
95
+ "@types/react-dom": "^19.2.3",
96
96
  "@types/react-measure": "^2.0.12",
97
97
  "@vitejs/plugin-react": "^4.7.0",
98
98
  "babel-plugin-react-compiler": "^19.0.0-beta-af1b7da-20250417",
@@ -114,7 +114,7 @@
114
114
  "index.css",
115
115
  "tailwind.config.js"
116
116
  ],
117
- "gitHead": "72d951d01d15ef5a7efcde6c63839f65964d2f7a",
117
+ "gitHead": "fa98925bad34308ed66e7ea68adcc07eb38184ae",
118
118
  "publishConfig": {
119
119
  "access": "public"
120
120
  }
@@ -42,10 +42,10 @@ export const BooleanSwitch = React.forwardRef(function BooleanSwitch({
42
42
  }}
43
43
  className={cls(
44
44
  size === "smallest" ? "w-[34px] h-[18px] min-w-[34px] min-h-[18px]" : size === "small" ? "w-[38px] h-[22px] min-w-[38px] min-h-[22px]" : "w-[44px] h-[26px] min-w-[44px] min-h-[26px]",
45
- "outline-none rounded-full relative shadow-sm",
45
+ "outline-none outline-hidden rounded-full relative shadow-sm",
46
46
  value ? (disabled
47
- ? "bg-white bg-opacity-54 dark:bg-surface-accent-950 border-surface-accent-100 dark:border-surface-accent-700 ring-1 ring-surface-accent-200 dark:ring-surface-accent-700"
48
- : "ring-secondary ring-1 bg-secondary dark:bg-secondary") : "bg-white bg-opacity-54 dark:bg-surface-accent-900 ring-1 ring-surface-accent-200 dark:ring-surface-accent-700",
47
+ ? "bg-white bg-opacity-54 bg-white/54 dark:bg-surface-accent-950 border-surface-accent-100 dark:border-surface-accent-700 ring-1 ring-surface-accent-200 dark:ring-surface-accent-700"
48
+ : "ring-secondary ring-1 bg-secondary dark:bg-secondary") : "bg-white bg-opacity-54 bg-white/54 dark:bg-surface-accent-900 ring-1 ring-surface-accent-200 dark:ring-surface-accent-700",
49
49
  className
50
50
  )}
51
51
  {...props}
@@ -46,14 +46,14 @@ const ButtonInner = React.memo(React.forwardRef<
46
46
  "border border-transparent bg-surface-100 hover:bg-surface-accent-200 text-text-primary dark:bg-surface-800 dark:hover:bg-surface-accent-700 dark:text-text-primary-dark hover:text-text-primary dark:text-text-primary-dark hover:dark:text-text-primary-dark": variant === "filled" && color === "neutral" && !disabled,
47
47
 
48
48
  // Text Variants
49
- "border border-transparent text-primary hover:text-primary hover:bg-surface-accent-200 hover:bg-opacity-75 dark:hover:bg-surface-accent-800": variant === "text" && color === "primary" && !disabled,
50
- "border border-transparent text-secondary hover:text-secondary hover:bg-surface-accent-200 hover:bg-opacity-75 dark:hover:bg-surface-accent-800": variant === "text" && color === "secondary" && !disabled,
51
- "border border-transparent text-red-500 hover:text-red-500 hover:bg-red-500 hover:bg-opacity-10": variant === "text" && color === "error" && !disabled,
49
+ "border border-transparent text-primary hover:text-primary hover:bg-surface-accent-200 hover:bg-opacity-75 hover:bg-surface-accent-200/75 dark:hover:bg-surface-accent-800": variant === "text" && color === "primary" && !disabled,
50
+ "border border-transparent text-secondary hover:text-secondary hover:bg-surface-accent-200 hover:bg-opacity-75 hover:bg-surface-accent-200/75 dark:hover:bg-surface-accent-800": variant === "text" && color === "secondary" && !disabled,
51
+ "border border-transparent text-red-500 hover:text-red-500 hover:bg-red-500 hover:bg-opacity-10 hover:bg-red-500/10": variant === "text" && color === "error" && !disabled,
52
52
  "border border-transparent text-text-primary hover:text-text-primary dark:text-text-primary-dark hover:dark:text-text-primary-dark hover:bg-surface-accent-200 hover:dark:bg-surface-700": variant === "text" && color === "text" && !disabled,
53
53
  "border border-transparent text-text-primary hover:text-text-primary hover:bg-surface-accent-200 dark:text-text-primary-dark dark:hover:text-text-primary-dark dark:hover:bg-surface-accent-700": variant === "text" && color === "neutral" && !disabled,
54
54
 
55
55
  // Outlined Variants
56
- "border border-primary text-primary hover:text-primary hover:bg-primary-bg": variant === "outlined" && color === "primary" && !disabled,
56
+ "border border-primary text-primary hover:text-primary hover:bg-primary-bg hover:bg-primary/10": variant === "outlined" && color === "primary" && !disabled,
57
57
  "border border-secondary text-secondary hover:text-secondary hover:bg-secondary-bg": variant === "outlined" && color === "secondary" && !disabled,
58
58
  "border border-red-500 text-red-500 hover:text-red-500 hover:bg-red-500 hover:text-white": variant === "outlined" && color === "error" && !disabled,
59
59
  "border border-surface-accent-400 text-text-primary hover:text-text-primary dark:text-text-primary-dark hover:bg-surface-accent-200": variant === "outlined" && color === "text" && !disabled,
@@ -63,7 +63,7 @@ const ButtonInner = React.memo(React.forwardRef<
63
63
  "text-text-disabled dark:text-text-disabled-dark": disabled,
64
64
  "border border-transparent opacity-50": variant === "text" && disabled,
65
65
  "border border-surface-500 opacity-50": variant === "outlined" && disabled,
66
- "border border-transparent bg-surface-300 dark:bg-surface-500 opacity-40": variant === "filled" && disabled,
66
+ "border border-transparent bg-surface-300 dark:bg-surface-500 opacity-40 bg-surface-300/40 dark:bg-surface-500/40": variant === "filled" && disabled,
67
67
  });
68
68
 
69
69
  const sizeClasses = cls(
@@ -6,17 +6,17 @@ import { cls } from "../util";
6
6
  type CardProps = {
7
7
  children: React.ReactNode;
8
8
  style?: React.CSSProperties;
9
- onClick?: () => void;
9
+ onClick?: (e?: React.MouseEvent) => void;
10
10
  className?: string;
11
11
  };
12
12
 
13
13
  const Card = React.forwardRef<HTMLDivElement, CardProps>(({
14
- children,
15
- className,
16
- onClick,
17
- style,
18
- ...props
19
- }, ref) => {
14
+ children,
15
+ className,
16
+ onClick,
17
+ style,
18
+ ...props
19
+ }, ref) => {
20
20
  const onKeyPress = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
21
21
  if (e.key === "Enter" || e.key === " ") {
22
22
  onClick?.();
@@ -71,7 +71,7 @@ export const Checkbox = React.memo(({
71
71
  padding ? paddingClasses[size] : "",
72
72
  outerSizeClasses[size],
73
73
  "inline-flex items-center justify-center text-sm font-medium focus:outline-none transition-colors ease-in-out duration-150",
74
- onCheckedChange ? "rounded-full hover:bg-surface-accent-200 hover:bg-opacity-75 dark:hover:bg-surface-accent-700 dark:hover:bg-opacity-75" : "",
74
+ onCheckedChange ? "rounded-full hover:bg-surface-accent-200 hover:bg-opacity-75 hover:bg-surface-accent-200/75 dark:hover:bg-surface-accent-700 dark:hover:bg-opacity-75 dark:hover:bg-surface-accent-700/75" : "",
75
75
  onCheckedChange ? "cursor-pointer" : "cursor-default"
76
76
  )}>
77
77
  <div
@@ -0,0 +1,134 @@
1
+ import React from "react";
2
+ import { CHIP_COLORS, cls } from "../util";
3
+ import { ChipColorKey, ChipColorScheme } from "./Chip";
4
+ import { CheckIcon } from "../icons";
5
+ import { Tooltip } from "./Tooltip";
6
+
7
+ export interface ColorPickerProps {
8
+ /**
9
+ * Currently selected color key
10
+ */
11
+ value?: ChipColorKey;
12
+ /**
13
+ * Callback when color selection changes. Passes undefined when "Auto" is selected.
14
+ */
15
+ onChange: (colorKey: ChipColorKey | undefined) => void;
16
+ /**
17
+ * Size of the color swatches
18
+ */
19
+ size?: "small" | "medium";
20
+ /**
21
+ * Whether to show the "Auto" option that clears the selection
22
+ */
23
+ allowClear?: boolean;
24
+ /**
25
+ * Whether the picker is disabled
26
+ */
27
+ disabled?: boolean;
28
+ }
29
+
30
+ // Base colors in display order
31
+ const BASE_COLORS = ["blue", "cyan", "teal", "green", "yellow", "orange", "red", "pink", "purple", "gray"] as const;
32
+
33
+ // Variants in display order (darker to lighter for better visual flow)
34
+ const VARIANTS = ["Darker", "Dark", "Light", "Lighter"] as const;
35
+
36
+ // Helper to get readable name from color key
37
+ function getColorDisplayName(colorKey: ChipColorKey): string {
38
+ // Convert camelCase to readable format: "blueLighter" -> "Blue Lighter"
39
+ const base = colorKey.replace(/(Lighter|Light|Dark|Darker)$/, "");
40
+ const variant = colorKey.replace(base, "");
41
+ return `${base.charAt(0).toUpperCase()}${base.slice(1)} ${variant}`;
42
+ }
43
+
44
+ /**
45
+ * A color picker component that displays a grid of predefined CHIP_COLORS.
46
+ * Used for selecting colors for enum values, tags, and other chip-based UI elements.
47
+ *
48
+ * @group Form components
49
+ */
50
+ export function ColorPicker({
51
+ value,
52
+ onChange,
53
+ size = "medium",
54
+ allowClear = true,
55
+ disabled = false
56
+ }: ColorPickerProps) {
57
+
58
+ const swatchSize = size === "small" ? "w-5 h-5" : "w-6 h-6";
59
+ const checkSize = size === "small" ? 12 : 14;
60
+
61
+ return (
62
+ <div className="flex flex-col gap-2">
63
+ {allowClear && (
64
+ <button
65
+ type="button"
66
+ disabled={disabled}
67
+ onClick={() => onChange(undefined)}
68
+ className={cls(
69
+ "flex items-center gap-2 px-2 py-1 rounded text-sm transition-colors",
70
+ "hover:bg-surface-accent-100 dark:hover:bg-surface-accent-800",
71
+ disabled && "opacity-50 cursor-not-allowed",
72
+ !value && "bg-surface-accent-100 dark:bg-surface-accent-800 font-medium"
73
+ )}
74
+ >
75
+ <div className={cls(
76
+ swatchSize,
77
+ "rounded-full border-2 border-dashed border-surface-accent-400 dark:border-surface-accent-600",
78
+ "flex items-center justify-center"
79
+ )}>
80
+ {!value && <CheckIcon size={checkSize} />}
81
+ </div>
82
+ <span className="text-surface-accent-700 dark:text-surface-accent-300">
83
+ Auto (based on ID)
84
+ </span>
85
+ </button>
86
+ )}
87
+
88
+ <div className="grid grid-cols-10 gap-1">
89
+ {VARIANTS.map((variant) => (
90
+ BASE_COLORS.map((base) => {
91
+ const colorKey = `${base}${variant}` as ChipColorKey;
92
+ const colorScheme = CHIP_COLORS[colorKey] as ChipColorScheme;
93
+ const isSelected = value === colorKey;
94
+ const displayName = getColorDisplayName(colorKey);
95
+
96
+ return (
97
+ <Tooltip
98
+ key={colorKey}
99
+ title={displayName}
100
+ delayDuration={300}
101
+ >
102
+ <button
103
+ type="button"
104
+ disabled={disabled}
105
+ onClick={() => onChange(colorKey)}
106
+ className={cls(
107
+ swatchSize,
108
+ "rounded-full transition-all flex items-center justify-center",
109
+ "hover:scale-110 hover:shadow-md",
110
+ "focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-1",
111
+ disabled && "opacity-50 cursor-not-allowed hover:scale-100",
112
+ isSelected && "ring-2 ring-primary ring-offset-1"
113
+ )}
114
+ style={{
115
+ backgroundColor: colorScheme.color,
116
+ }}
117
+ aria-label={displayName}
118
+ aria-pressed={isSelected}
119
+ >
120
+ {isSelected && (
121
+ <CheckIcon
122
+ size={checkSize}
123
+ style={{ color: colorScheme.text }}
124
+ />
125
+ )}
126
+ </button>
127
+ </Tooltip>
128
+ );
129
+ })
130
+ ))}
131
+ </div>
132
+ </div>
133
+ );
134
+ }
@@ -23,22 +23,30 @@ export type DateTimeFieldProps = {
23
23
  inputClassName?: string;
24
24
  invisible?: boolean;
25
25
  locale?: string;
26
+ /**
27
+ * IANA timezone string (e.g., "America/New_York", "Europe/London").
28
+ * Used to display and input dates in the specified timezone.
29
+ * The value passed to onChange will always be in UTC.
30
+ * If not provided, uses the user's local timezone.
31
+ */
32
+ timezone?: string;
26
33
  };
27
34
 
28
35
  export const DateTimeField: React.FC<DateTimeFieldProps> = ({
29
- value,
30
- label,
31
- onChange,
32
- disabled,
33
- clearable,
34
- mode = "date",
35
- error,
36
- size = "large",
37
- className,
38
- style,
39
- inputClassName,
40
- invisible,
41
- }) => {
36
+ value,
37
+ label,
38
+ onChange,
39
+ disabled,
40
+ clearable,
41
+ mode = "date",
42
+ error,
43
+ size = "large",
44
+ className,
45
+ style,
46
+ inputClassName,
47
+ invisible,
48
+ timezone,
49
+ }) => {
42
50
  const inputRef = useRef<HTMLInputElement>(null);
43
51
  const [focused, setFocused] = useState(false);
44
52
  const [internalValue, setInternalValue] = useState<string>("");
@@ -54,7 +62,7 @@ export const DateTimeField: React.FC<DateTimeFieldProps> = ({
54
62
  onChange?.(null);
55
63
  };
56
64
 
57
- // Convert Date object to input value string
65
+ // Convert UTC Date to display string in the specified timezone
58
66
  const valueAsInputValue = (
59
67
  dateValue: Date | null,
60
68
  mode: "date" | "date_time"
@@ -63,20 +71,80 @@ export const DateTimeField: React.FC<DateTimeFieldProps> = ({
63
71
  return "";
64
72
  }
65
73
  const pad = (n: number) => n.toString().padStart(2, "0");
66
- const year = dateValue.getFullYear();
67
- const month = pad(dateValue.getMonth() + 1);
68
- const day = pad(dateValue.getDate());
74
+
75
+ // Use Intl.DateTimeFormat to get date parts in the target timezone
76
+ const options: Intl.DateTimeFormatOptions = {
77
+ year: "numeric",
78
+ month: "2-digit",
79
+ day: "2-digit",
80
+ hour: "2-digit",
81
+ minute: "2-digit",
82
+ hour12: false,
83
+ timeZone: timezone, // undefined = local timezone
84
+ };
85
+
86
+ const formatter = new Intl.DateTimeFormat("en-CA", options);
87
+ const parts = formatter.formatToParts(dateValue);
88
+
89
+ const getPart = (type: string) => parts.find(p => p.type === type)?.value ?? "";
90
+
91
+ const year = getPart("year");
92
+ const month = getPart("month");
93
+ const day = getPart("day");
69
94
 
70
95
  if (mode === "date") {
71
96
  return `${year}-${month}-${day}`;
72
97
  } else {
73
- const hours = pad(dateValue.getHours());
74
- const minutes = pad(dateValue.getMinutes());
98
+ const hours = getPart("hour");
99
+ const minutes = getPart("minute");
75
100
  return `${year}-${month}-${day}T${hours}:${minutes}`;
76
101
  }
77
102
  };
78
103
 
79
- // Handle input value change
104
+ // Get the UTC offset for a specific date in the target timezone (in minutes)
105
+ const getTimezoneOffsetMinutes = (date: Date, tz?: string): number => {
106
+ if (!tz) {
107
+ // Local timezone: use built-in getTimezoneOffset (returns offset in minutes, inverted sign)
108
+ return -date.getTimezoneOffset();
109
+ }
110
+ // For named timezones, calculate the offset by comparing formatted times
111
+ const utcFormatter = new Intl.DateTimeFormat("en-CA", {
112
+ year: "numeric", month: "2-digit", day: "2-digit",
113
+ hour: "2-digit", minute: "2-digit", hour12: false,
114
+ timeZone: "UTC"
115
+ });
116
+ const tzFormatter = new Intl.DateTimeFormat("en-CA", {
117
+ year: "numeric", month: "2-digit", day: "2-digit",
118
+ hour: "2-digit", minute: "2-digit", hour12: false,
119
+ timeZone: tz
120
+ });
121
+
122
+ const utcParts = utcFormatter.formatToParts(date);
123
+ const tzParts = tzFormatter.formatToParts(date);
124
+
125
+ const getPart = (parts: Intl.DateTimeFormatPart[], type: string) =>
126
+ parseInt(parts.find(p => p.type === type)?.value ?? "0", 10);
127
+
128
+ const utcDate = new Date(Date.UTC(
129
+ getPart(utcParts, "year"),
130
+ getPart(utcParts, "month") - 1,
131
+ getPart(utcParts, "day"),
132
+ getPart(utcParts, "hour"),
133
+ getPart(utcParts, "minute")
134
+ ));
135
+
136
+ const tzDate = new Date(Date.UTC(
137
+ getPart(tzParts, "year"),
138
+ getPart(tzParts, "month") - 1,
139
+ getPart(tzParts, "day"),
140
+ getPart(tzParts, "hour"),
141
+ getPart(tzParts, "minute")
142
+ ));
143
+
144
+ return (tzDate.getTime() - utcDate.getTime()) / 60000;
145
+ };
146
+
147
+ // Handle input value change - convert from display timezone to UTC
80
148
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
81
149
  const inputValue = e.target.value;
82
150
  setInternalValue(inputValue);
@@ -88,21 +156,42 @@ export const DateTimeField: React.FC<DateTimeFieldProps> = ({
88
156
  }
89
157
 
90
158
  try {
91
- const parsed = new Date(inputValue);
92
- if (isNaN(parsed.getTime())) {
93
- throw new Error("Invalid date");
94
- }
159
+ let year: number, month: number, day: number, hours = 0, minutes = 0;
95
160
 
96
- let newDate: Date;
97
161
  if (mode === "date") {
98
- // Adjust for timezone offset for date-only inputs
99
- const userTimezoneOffset = parsed.getTimezoneOffset() * 60000;
100
- newDate = new Date(parsed.getTime() + userTimezoneOffset);
162
+ // Parse date-only input: "YYYY-MM-DD"
163
+ [year, month, day] = inputValue.split("-").map(Number);
164
+ } else {
165
+ // Parse datetime-local input: "YYYY-MM-DDTHH:MM"
166
+ const [datePart, timePart] = inputValue.split("T");
167
+ [year, month, day] = datePart.split("-").map(Number);
168
+ [hours, minutes] = timePart.split(":").map(Number);
169
+ }
170
+
171
+ let resultDate: Date;
172
+
173
+ if (!timezone) {
174
+ // No timezone specified: interpret input as local time (backward compatible)
175
+ resultDate = new Date(year, month - 1, day, hours, minutes);
101
176
  } else {
102
- newDate = parsed;
177
+ // Timezone specified: interpret input as that timezone and convert to UTC
178
+ // We need to find the UTC equivalent of the entered time in the target timezone
179
+
180
+ // Create a reference UTC date to calculate the offset for this moment
181
+ const refUtcDate = new Date(Date.UTC(year, month - 1, day, hours, minutes));
182
+ const offsetMinutes = getTimezoneOffsetMinutes(refUtcDate, timezone);
183
+
184
+ // Convert from target timezone to UTC:
185
+ // If user entered 00:00 in Mexico (UTC-6, offset=-360), we subtract the offset
186
+ // Date.UTC gives us 00:00 UTC, subtracting -360 minutes (= adding 360 min) gives 06:00 UTC
187
+ resultDate = new Date(Date.UTC(year, month - 1, day, hours, minutes) - offsetMinutes * 60000);
188
+ }
189
+
190
+ if (isNaN(resultDate.getTime())) {
191
+ throw new Error("Invalid date");
103
192
  }
104
193
 
105
- onChange?.(newDate);
194
+ onChange?.(resultDate);
106
195
  } catch (e) {
107
196
  // Don't call onChange with null while typing
108
197
  return;
@@ -156,7 +245,7 @@ export const DateTimeField: React.FC<DateTimeFieldProps> = ({
156
245
  disabled ? "opacity-50" : ""
157
246
  )}
158
247
  shrink={true}
159
- // shrink={hasValue || focused}
248
+ // shrink={hasValue || focused}
160
249
  >
161
250
  {label}
162
251
  </InputLabel>
@@ -193,20 +282,20 @@ export const DateTimeField: React.FC<DateTimeFieldProps> = ({
193
282
  }}
194
283
  className="absolute right-3 top-1/2 transform -translate-y-1/2 !text-surface-accent-500"
195
284
  >
196
- <CalendarMonthIcon color={"disabled"}/>
285
+ <CalendarMonthIcon color={"disabled"} />
197
286
  </IconButton>
198
287
  {clearable && value && (
199
288
  <IconButton
200
289
  onClick={handleClear}
201
290
  className="absolute right-14 top-1/2 transform -translate-y-1/2 text-surface-accent-400 "
202
291
  >
203
- <CloseIcon/>
292
+ <CloseIcon />
204
293
  </IconButton>
205
294
  )}
206
295
  </div>
207
296
  {invalidValue && (
208
297
  <div className="flex items-center m-2">
209
- <ErrorIcon size={"small"} color={"error"}/>
298
+ <ErrorIcon size={"small"} color={"error"} />
210
299
  <div className="pl-2">
211
300
  <Typography variant={"body2"}>
212
301
  Invalid date value for this field
@@ -4,7 +4,7 @@ import { TextField, TextFieldProps } from "./index";
4
4
 
5
5
  export function DebouncedTextField<T extends string | number>(props: TextFieldProps<T>) {
6
6
 
7
- const previousEventRef = React.useRef<ChangeEvent<any>>();
7
+ const previousEventRef = React.useRef<ChangeEvent<any>>(undefined);
8
8
  const [internalValue, setInternalValue] = React.useState(props.value);
9
9
 
10
10
  const deferredValue = useDeferredValue(internalValue);
@@ -28,6 +28,6 @@ export function DebouncedTextField<T extends string | number>(props: TextFieldPr
28
28
  }, []);
29
29
 
30
30
  return <TextField {...props}
31
- onChange={internalOnChange}
32
- value={internalValue}/>
31
+ onChange={internalOnChange}
32
+ value={internalValue} />
33
33
  }
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from "react";
3
3
  import * as DialogPrimitive from "@radix-ui/react-dialog";
4
4
  import { paperMixin } from "../styles";
5
5
  import { cls } from "../util";
6
+ import { usePortalContainer } from "../hooks/PortalContainerContext";
6
7
 
7
8
  export type DialogProps = {
8
9
  open?: boolean;
@@ -24,21 +25,22 @@ export type DialogProps = {
24
25
  * If `true`, the dialog will not focus the first focusable element when opened.
25
26
  */
26
27
  disableInitialFocus?: boolean;
28
+ portalContainer?: HTMLElement | null;
27
29
  };
28
30
 
29
31
  const widthClasses = {
30
- xs: "max-w-xs min-w-xs w-xs",
31
- sm: "max-w-sm min-w-sm w-sm",
32
- md: "max-w-md min-w-md w-md",
33
- lg: "max-w-lg min-w-lg w-lg",
34
- xl: "max-w-xl min-w-xl w-xl",
35
- "2xl": "max-w-2xl min-w-2xl w-2xl",
36
- "3xl": "max-w-3xl min-w-3xl w-3xl",
37
- "4xl": "max-w-4xl min-w-4xl w-4xl",
38
- "5xl": "max-w-5xl min-w-5xl w-5xl",
39
- "6xl": "max-w-6xl min-w-6xl w-6xl",
40
- "7xl": "max-w-7xl min-w-7xl w-7xl",
41
- full: "max-w-full min-w-full w-full"
32
+ xs: "max-w-xs w-xs",
33
+ sm: "max-w-sm w-sm",
34
+ md: "max-w-md w-md",
35
+ lg: "max-w-lg w-lg",
36
+ xl: "max-w-xl w-xl",
37
+ "2xl": "max-w-2xl w-2xl",
38
+ "3xl": "max-w-3xl w-3xl",
39
+ "4xl": "max-w-4xl w-4xl",
40
+ "5xl": "max-w-5xl w-5xl",
41
+ "6xl": "max-w-6xl w-6xl",
42
+ "7xl": "max-w-7xl w-7xl",
43
+ full: "max-w-full w-full"
42
44
  };
43
45
 
44
46
  export const Dialog = ({
@@ -57,15 +59,22 @@ export const Dialog = ({
57
59
  onEscapeKeyDown,
58
60
  onPointerDownOutside,
59
61
  onInteractOutside,
60
- disableInitialFocus = true
62
+ disableInitialFocus = true,
63
+ portalContainer
61
64
  }: DialogProps) => {
62
65
  const [displayed, setDisplayed] = useState(false);
63
66
 
67
+ // Get the portal container from context
68
+ const contextContainer = usePortalContainer();
69
+
70
+ // Prioritize manual prop, fallback to context container
71
+ const finalContainer = (portalContainer ?? contextContainer ?? undefined) as HTMLElement | undefined;
72
+
64
73
  useEffect(() => {
65
74
  if (!open) {
66
75
  const timeout = setTimeout(() => {
67
76
  setDisplayed(false);
68
- }, 150);
77
+ }, 100);
69
78
  return () => clearTimeout(timeout);
70
79
  } else {
71
80
  setDisplayed(true);
@@ -78,12 +87,12 @@ export const Dialog = ({
78
87
  <DialogPrimitive.Root open={displayed || open}
79
88
  modal={modal}
80
89
  onOpenChange={onOpenChange}>
81
- <DialogPrimitive.Portal>
90
+ <DialogPrimitive.Portal container={finalContainer}>
82
91
 
83
92
  <div className={cls("fixed inset-0 z-30", containerClassName)}>
84
93
 
85
94
  <DialogPrimitive.Overlay
86
- className={cls("fixed inset-0 transition-opacity z-20 ease-in-out duration-200 bg-black bg-opacity-50 dark:bg-opacity-60 backdrop-blur-sm ",
95
+ className={cls("fixed inset-0 transition-opacity z-20 ease-in-out duration-200 bg-black dark:bg-opacity-60 dark:bg-black/60 bg-opacity-50 bg-black/50 dark: bg-black/60 backdrop-blur-sm ",
87
96
  displayed && open ? "opacity-100" : "opacity-0",
88
97
  "z-20 fixed top-0 left-0 w-full h-full flex justify-center items-center"
89
98
  )}
@@ -19,7 +19,7 @@ export function DialogActions({
19
19
  defaultBorderMixin,
20
20
  "pt-2 pb-4 px-4 border-t flex flex-row items-center justify-end bottom-0 right-0 left-0 text-right z-2 gap-2",
21
21
  position,
22
- "bg-white bg-opacity-60 dark:bg-surface-900 dark:bg-opacity-60",
22
+ "bg-white bg-opacity-60 bg-white/60 dark:bg-surface-900 dark:bg-opacity-60 dark:bg-surface-900/60",
23
23
  translucent ? "backdrop-blur-sm" : "",
24
24
  className)}>
25
25
  {children}
@@ -95,7 +95,7 @@ export function ExpandablePanel({
95
95
  <div
96
96
  className={cls(
97
97
  "rounded-t flex items-center justify-between w-full min-h-[52px]",
98
- "hover:bg-surface-accent-200 hover:bg-opacity-40 dark:hover:bg-surface-800 dark:hover:bg-opacity-40",
98
+ "hover:bg-surface-accent-200 hover:bg-opacity-40 hover:bg-surface-accent-200/40 dark:hover:bg-surface-800 dark:hover:bg-opacity-40 dark:hover:bg-surface-800/40",
99
99
  invisible ? "border-b px-2" : "p-4",
100
100
  open ? "py-6" : "py-4",
101
101
  "transition-all duration-200",