diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/controls/components/logs-refresh.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/controls/components/logs-refresh.tsx index 2f6e376b05..b795f9602f 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/controls/components/logs-refresh.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/controls/components/logs-refresh.tsx @@ -1,6 +1,6 @@ -import { RefreshButton } from "@/components/logs/refresh-button"; import { trpc } from "@/lib/trpc/client"; import { useQueryTime } from "@/providers/query-time-provider"; +import { RefreshButton } from "@unkey/ui"; import { useFilters } from "../../../hooks/use-filters"; export const LogsRefresh = () => { diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/controls/components/logs-refresh.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/controls/components/logs-refresh.tsx index 2f6e376b05..b795f9602f 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/controls/components/logs-refresh.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/controls/components/logs-refresh.tsx @@ -1,6 +1,6 @@ -import { RefreshButton } from "@/components/logs/refresh-button"; import { trpc } from "@/lib/trpc/client"; import { useQueryTime } from "@/providers/query-time-provider"; +import { RefreshButton } from "@unkey/ui"; import { useFilters } from "../../../hooks/use-filters"; export const LogsRefresh = () => { diff --git a/apps/dashboard/app/(app)/apis/_components/controls/components/logs-refresh.tsx b/apps/dashboard/app/(app)/apis/_components/controls/components/logs-refresh.tsx index 3a3fa3a317..38b2d3c2ac 100644 --- a/apps/dashboard/app/(app)/apis/_components/controls/components/logs-refresh.tsx +++ b/apps/dashboard/app/(app)/apis/_components/controls/components/logs-refresh.tsx @@ -1,5 +1,5 @@ -import { RefreshButton } from "@/components/logs/refresh-button"; import { trpc } from "@/lib/trpc/client"; +import { RefreshButton } from "@unkey/ui"; import { useRouter } from "next/navigation"; import { useFilters } from "../../hooks/use-filters"; diff --git a/apps/dashboard/app/(app)/audit/components/controls/components/logs-refresh.tsx b/apps/dashboard/app/(app)/audit/components/controls/components/logs-refresh.tsx index ae3e7c63ee..5cc7caa077 100644 --- a/apps/dashboard/app/(app)/audit/components/controls/components/logs-refresh.tsx +++ b/apps/dashboard/app/(app)/audit/components/controls/components/logs-refresh.tsx @@ -1,5 +1,5 @@ -import { RefreshButton } from "@/components/logs/refresh-button"; import { trpc } from "@/lib/trpc/client"; +import { RefreshButton } from "@unkey/ui"; import { useFilters } from "../../../hooks/use-filters"; export const LogsRefresh = () => { diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-refresh.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-refresh.tsx index 9c924c6209..5ec250fa02 100644 --- a/apps/dashboard/app/(app)/logs/components/controls/components/logs-refresh.tsx +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-refresh.tsx @@ -1,6 +1,6 @@ -import { RefreshButton } from "@/components/logs/refresh-button"; import { trpc } from "@/lib/trpc/client"; import { useQueryTime } from "@/providers/query-time-provider"; +import { RefreshButton } from "@unkey/ui"; import { useLogsContext } from "../../../context/logs"; import { useFilters } from "../../../hooks/use-filters"; diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-refresh.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-refresh.tsx index 8ab9cd22b3..ad52103e13 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-refresh.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/_overview/components/controls/components/logs-refresh.tsx @@ -1,6 +1,6 @@ -import { RefreshButton } from "@/components/logs/refresh-button"; import { trpc } from "@/lib/trpc/client"; import { useQueryTime } from "@/providers/query-time-provider"; +import { RefreshButton } from "@unkey/ui"; import { useFilters } from "../../../hooks/use-filters"; export const LogsRefresh = () => { diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-refresh.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-refresh.tsx index 3bf0b5cf5b..504ee409ef 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-refresh.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/logs/components/controls/components/logs-refresh.tsx @@ -1,6 +1,6 @@ -import { RefreshButton } from "@/components/logs/refresh-button"; import { trpc } from "@/lib/trpc/client"; import { useQueryTime } from "@/providers/query-time-provider"; +import { RefreshButton } from "@unkey/ui"; import { useRatelimitLogsContext } from "../../../context/logs"; import { useFilters } from "../../../hooks/use-filters"; diff --git a/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-refresh.tsx b/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-refresh.tsx index aa6b4ca3ac..e49fc8a123 100644 --- a/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-refresh.tsx +++ b/apps/dashboard/app/(app)/ratelimits/_components/controls/components/logs-refresh.tsx @@ -1,5 +1,5 @@ -import { RefreshButton } from "@/components/logs/refresh-button"; import { trpc } from "@/lib/trpc/client"; +import { RefreshButton } from "@unkey/ui"; import { useRouter } from "next/navigation"; import { useFilters } from "../../hooks/use-filters"; diff --git a/apps/engineering/content/design/components/buttons/button.mdx b/apps/engineering/content/design/components/buttons/button.mdx index 148b38424f..5317f42630 100644 --- a/apps/engineering/content/design/components/buttons/button.mdx +++ b/apps/engineering/content/design/components/buttons/button.mdx @@ -164,7 +164,7 @@ The Button component accepts all standard HTML button attributes plus the follow ## Usage Guidelines diff --git a/apps/engineering/content/design/components/buttons/refresh-button.examples.tsx b/apps/engineering/content/design/components/buttons/refresh-button.examples.tsx new file mode 100644 index 0000000000..3d10f71b23 --- /dev/null +++ b/apps/engineering/content/design/components/buttons/refresh-button.examples.tsx @@ -0,0 +1,144 @@ +"use client"; +import { RenderComponentWithSnippet } from "@/app/components/render"; +import { Button, RefreshButton } from "@unkey/ui"; +import { useState } from "react"; + +export const Default = () => { + const [refreshCount, setRefreshCount] = useState(0); + + const handleRefresh = () => { + setRefreshCount((prev) => prev + 1); + }; + + return ( + +
+
+

Basic Refresh Button

+
+ + Refresh count: {refreshCount} +
+
+ +
+

With Custom Styling

+
+ + Try clicking or pressing ⌥+⇧+R +
+
+
+
+ ); +}; + +export const WithLiveMode = () => { + const [refreshCount, setRefreshCount] = useState(0); + const [isLive, setIsLive] = useState(true); + const [liveData, setLiveData] = useState(`Live data: ${Date.now()}`); + + const handleRefresh = () => { + setRefreshCount((prev) => prev + 1); + setLiveData(`Live data: ${Date.now()}`); + }; + + return ( + +
+
+

With Live Mode Integration

+
+ +
+ Refresh count: {refreshCount} + {liveData} + Live mode: {isLive ? "ON" : "OFF"} +
+
+
+ +
+

Live Mode Toggle

+
+ + + Live mode will be temporarily disabled during refresh + +
+
+
+
+ ); +}; + +export const DisabledState = () => { + const [timeFilter, setTimeFilter] = useState("1h"); + const [refreshCount, setRefreshCount] = useState(0); + + const handleRefresh = () => { + setRefreshCount((prev) => prev + 1); + }; + + // Disable refresh when "all" time filter is selected + const isEnabled = timeFilter !== "all"; + + return ( + +
+
+

Conditional Enablement

+
+ + + Refresh count: {refreshCount} +
+
+ +
+

Disabled State

+
+ + + Hover over the disabled button to see the tooltip + +
+
+ +
+

State Comparison

+
+
+ Enabled: + +
+
+ Disabled: + +
+
+
+
+
+ ); +}; diff --git a/apps/engineering/content/design/components/buttons/refresh-button.mdx b/apps/engineering/content/design/components/buttons/refresh-button.mdx new file mode 100644 index 0000000000..6379a4e867 --- /dev/null +++ b/apps/engineering/content/design/components/buttons/refresh-button.mdx @@ -0,0 +1,75 @@ +--- +title: "RefreshButton" +description: "A button component for refreshing data with keyboard shortcuts and live mode support" +--- +import { Default, WithLiveMode, DisabledState } from "./refresh-button.examples"; + +## Features + +- **Keyboard Shortcut**: Built-in support for ⌥+⇧+R (Option+Shift+R) shortcut +- **Loading States**: Visual feedback during refresh operations with a 1-second timeout +- **Live Mode Integration**: Automatic handling of live mode toggling during refresh +- **Accessibility**: Full keyboard navigation and screen reader support +- **Tooltip Integration**: Contextual help when refresh is unavailable +- **Visual Feedback**: Clear indication of refresh status and availability + +## Basic Usage + +The most common use case is a simple refresh button that calls a refresh function when clicked or when the keyboard shortcut is pressed. + + + +## With Live Mode + +When used in contexts with live data streaming, the RefreshButton can automatically manage live mode state during refresh operations. + + + +## Disabled State + +The button can be disabled when refresh is not available, typically when no relative time filter is selected. + + + +## Props + +| Prop | Type | Description | +|------|------|-------------| +| `onRefresh` | `() => void` | Function called when refresh is triggered | +| `isEnabled` | `boolean` | Whether the refresh functionality is available | +| `isLive` | `boolean?` | Current live mode state (optional) | +| `toggleLive?` | `(value: boolean) => void` | Function to toggle live mode | + +## Keyboard Shortcuts + +The RefreshButton automatically registers the following keyboard shortcut: + +- **⌥+⇧+R** (Option+Shift+R): Triggers the refresh operation + +The shortcut is only active when `isEnabled` is true and the button is not in a loading state. + +## Behavior + +### Refresh Flow + +1. **Click or Shortcut**: User clicks the button or presses ⌥+⇧+R +2. **Live Mode Handling**: If live mode is enabled, it's temporarily disabled +3. **Loading State**: Button shows loading indicator for 1 second +4. **Refresh Execution**: The `onRefresh` function is called +5. **State Restoration**: After timeout, live mode is restored if it was previously enabled + +### Disabled State + +When `isEnabled` is false: +- Button appears disabled +- Tooltip shows "Refresh unavailable - please select a relative time filter in the 'Since' dropdown" +- Keyboard shortcut is disabled +- No refresh operations can be triggered + +### Loading State + +During refresh operations: +- Button shows a loading spinner +- Text remains visible, but the button is non-interactive +- Keyboard shortcut is disabled +- Live mode is temporarily disabled if enabled \ No newline at end of file diff --git a/internal/ui/package.json b/internal/ui/package.json index 4f2a573106..c308fece9b 100644 --- a/internal/ui/package.json +++ b/internal/ui/package.json @@ -8,6 +8,7 @@ "author": "Andreas Thomas", "license": "AGPL-3.0", "devDependencies": { + "@testing-library/react": "^16.2.0", "@types/node": "^20.14.9", "@types/react": "^18.3.11", "@unkey/tsconfig": "workspace:^", diff --git a/internal/ui/src/components/button.tsx b/internal/ui/src/components/buttons/button.tsx similarity index 99% rename from internal/ui/src/components/button.tsx rename to internal/ui/src/components/buttons/button.tsx index bff1f0855d..869abd664d 100644 --- a/internal/ui/src/components/button.tsx +++ b/internal/ui/src/components/buttons/button.tsx @@ -2,8 +2,8 @@ import { Slot } from "@radix-ui/react-slot"; import { type VariantProps, cva } from "class-variance-authority"; import * as React from "react"; -import { cn } from "../lib/utils"; -import { AnimatedLoadingSpinner } from "./animated-loading-spinner"; +import { cn } from "../../lib/utils"; +import { AnimatedLoadingSpinner } from "../animated-loading-spinner"; // Hack to populate fumadocs' AutoTypeTable export type DocumentedButtonProps = VariantProps & { diff --git a/internal/ui/src/components/copy-button.tsx b/internal/ui/src/components/buttons/copy-button.tsx similarity index 97% rename from internal/ui/src/components/copy-button.tsx rename to internal/ui/src/components/buttons/copy-button.tsx index 381ad7487a..2304ebd853 100644 --- a/internal/ui/src/components/copy-button.tsx +++ b/internal/ui/src/components/buttons/copy-button.tsx @@ -1,7 +1,7 @@ "use client"; import { TaskChecked, TaskUnchecked } from "@unkey/icons"; import * as React from "react"; -import { cn } from "../lib/utils"; +import { cn } from "../../lib/utils"; import { Button, type ButtonProps } from "./button"; type CopyButtonProps = ButtonProps & { diff --git a/internal/ui/src/components/keyboard-button.tsx b/internal/ui/src/components/buttons/keyboard-button.tsx similarity index 97% rename from internal/ui/src/components/keyboard-button.tsx rename to internal/ui/src/components/buttons/keyboard-button.tsx index c831979134..55ba97596b 100644 --- a/internal/ui/src/components/keyboard-button.tsx +++ b/internal/ui/src/components/buttons/keyboard-button.tsx @@ -2,7 +2,7 @@ // biome-ignore lint: React in this context is used throughout, so biome will change to types because no APIs are used even though React is needed. import * as React from "react"; import type { ComponentProps } from "react"; -import { cn } from "../lib/utils"; +import { cn } from "../../lib/utils"; type ModifierKey = "⌘" | "⇧" | "CTRL" | "⌥"; diff --git a/apps/dashboard/components/logs/refresh-button/index.tsx b/internal/ui/src/components/buttons/refresh-button.tsx similarity index 75% rename from apps/dashboard/components/logs/refresh-button/index.tsx rename to internal/ui/src/components/buttons/refresh-button.tsx index 5c2407c191..b8261d1b8c 100644 --- a/apps/dashboard/components/logs/refresh-button/index.tsx +++ b/internal/ui/src/components/buttons/refresh-button.tsx @@ -1,7 +1,12 @@ -import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; +"use client"; import { Refresh3 } from "@unkey/icons"; -import { Button, InfoTooltip, KeyboardButton } from "@unkey/ui"; +// biome-ignore lint: React in this context is used throughout, so biome will change to types because no APIs are used even though React is needed. +import * as React from "react"; import { useState } from "react"; +import { useKeyboardShortcut } from "../../hooks/use-keyboard-shortcut"; +import { InfoTooltip } from "../info-tooltip"; +import { Button } from "./button"; +import { KeyboardButton } from "./keyboard-button"; type RefreshButtonProps = { onRefresh: () => void; @@ -12,7 +17,7 @@ type RefreshButtonProps = { const REFRESH_TIMEOUT_MS = 1000; -export const RefreshButton = ({ onRefresh, isEnabled, isLive, toggleLive }: RefreshButtonProps) => { +const RefreshButton = ({ onRefresh, isEnabled, isLive, toggleLive }: RefreshButtonProps) => { const [isLoading, setIsLoading] = useState(false); const [refreshTimeout, setRefreshTimeout] = useState(null); @@ -70,3 +75,6 @@ export const RefreshButton = ({ onRefresh, isEnabled, isLive, toggleLive }: Refr ); }; + +RefreshButton.displayName = "RefreshButton"; +export { RefreshButton, type RefreshButtonProps }; diff --git a/internal/ui/src/components/visible-button.tsx b/internal/ui/src/components/buttons/visible-button.tsx similarity index 97% rename from internal/ui/src/components/visible-button.tsx rename to internal/ui/src/components/buttons/visible-button.tsx index 39ed2820fd..7cab45264f 100644 --- a/internal/ui/src/components/visible-button.tsx +++ b/internal/ui/src/components/buttons/visible-button.tsx @@ -2,7 +2,7 @@ import { Eye, EyeSlash } from "@unkey/icons"; // biome-ignore lint: React in this context is used throughout, so biome will change to types because no APIs are used even though React is needed. import * as React from "react"; -import { cn } from "../lib/utils"; +import { cn } from "../../lib/utils"; import { Button, type ButtonProps } from "./button"; type VisibleButtonProps = ButtonProps & { diff --git a/internal/ui/src/components/date-time/components/calendar.tsx b/internal/ui/src/components/date-time/components/calendar.tsx index 58c0b39c32..4430b2881a 100644 --- a/internal/ui/src/components/date-time/components/calendar.tsx +++ b/internal/ui/src/components/date-time/components/calendar.tsx @@ -11,7 +11,7 @@ import { useNavigation, } from "react-day-picker"; import { cn } from "../../../lib/utils"; -import { buttonVariants } from "../../button"; +import { buttonVariants } from "../../buttons/button"; import { useDateTimeContext } from "../date-time"; function CustomCaptionComponent(props: CaptionProps) { diff --git a/internal/ui/src/components/dialog/navigable-dialog.tsx b/internal/ui/src/components/dialog/navigable-dialog.tsx index e03f67f573..b7bd70ed08 100644 --- a/internal/ui/src/components/dialog/navigable-dialog.tsx +++ b/internal/ui/src/components/dialog/navigable-dialog.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import { createContext, useCallback, useContext, useEffect, useState } from "react"; import type { FC, ReactNode } from "react"; import { cn } from "../../lib/utils"; -import { Button } from "../button"; +import { Button } from "../buttons/button"; import { Dialog, DialogContent } from "./dialog"; import { DefaultDialogContentArea, diff --git a/internal/ui/src/components/logs/control-cloud/control-pill.tsx b/internal/ui/src/components/logs/control-cloud/control-pill.tsx index 335096dc80..ea290bc539 100644 --- a/internal/ui/src/components/logs/control-cloud/control-pill.tsx +++ b/internal/ui/src/components/logs/control-cloud/control-pill.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { cn } from "../../../lib/utils"; import { XMark } from "@unkey/icons"; import { TimestampInfo } from "../../timestamp-info"; -import { Button } from "../../button"; +import { Button } from "../../buttons/button"; import type { FilterValue } from "../../../validation/filter.types"; import { formatOperator } from "./utils"; import { useEffect, useRef } from "react"; diff --git a/internal/ui/src/components/logs/control-cloud/index.tsx b/internal/ui/src/components/logs/control-cloud/index.tsx index 044715d4c5..fb3803b95a 100644 --- a/internal/ui/src/components/logs/control-cloud/index.tsx +++ b/internal/ui/src/components/logs/control-cloud/index.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import { useCallback, useState } from "react"; import { useKeyboardShortcut } from "../../../hooks/use-keyboard-shortcut"; import type { FilterValue } from "../../../validation/filter.types"; -import { KeyboardButton } from "../../keyboard-button"; +import { KeyboardButton } from "../../buttons/keyboard-button"; import { ControlPill } from "./control-pill"; import { defaultFormatValue } from "./utils"; diff --git a/internal/ui/src/hooks/use-persisted-form.tsx b/internal/ui/src/hooks/use-persisted-form.tsx new file mode 100644 index 0000000000..5e12b9a736 --- /dev/null +++ b/internal/ui/src/hooks/use-persisted-form.tsx @@ -0,0 +1,90 @@ +"use client"; +import { useCallback } from "react"; +import { type FieldValues, type UseFormProps, type UseFormReturn, useForm } from "react-hook-form"; + +export type StorageType = "memory" | "session" | "local"; + +export type UsePersistedFormReturn = UseFormReturn & { + clearPersistedData: () => void; + saveCurrentValues: () => void; + loadSavedValues: () => Promise; +}; + +// Create an in-memory storage singleton +const memoryStorage = new Map(); + +/** + * Custom hook that extends useForm with configurable storage persistence + * @param storageKey - Key used to store form data + * @param formOptions - Standard useForm options + * @param storageType - Where to persist data: "memory", "session", or "local" + */ +export function usePersistedForm>( + storageKey: string, + formOptions: UseFormProps, + storageType: StorageType = "session", +): UsePersistedFormReturn { + const methods = useForm(formOptions); + const { reset, getValues } = methods; + + // Helper to get the appropriate storage based on type + const getStorage = useCallback(() => { + switch (storageType) { + case "memory": + return { + getItem: (key: string) => memoryStorage.get(key) || null, + setItem: (key: string, value: string) => memoryStorage.set(key, value), + removeItem: (key: string) => memoryStorage.delete(key), + }; + case "local": + return localStorage; + default: + return sessionStorage; + } + }, [storageType]); + + const loadSavedValues = useCallback(async () => { + try { + const storage = getStorage(); + const savedState = storage.getItem(storageKey); + + if (savedState) { + const parsedState = JSON.parse(savedState); + reset(parsedState); + return true; + } + } catch (error) { + console.error(`Error loading saved form state from ${storageType} storage:`, error); + } + return false; + }, [reset, storageKey, getStorage, storageType]); + + // Save current form values + const saveCurrentValues = useCallback(() => { + try { + const storage = getStorage(); + const currentValues = getValues(); + storage.setItem(storageKey, JSON.stringify(currentValues)); + } catch (error) { + console.error(`Error saving form state to ${storageType} storage:`, error); + } + }, [getValues, storageKey, getStorage, storageType]); + + // Clear persisted data + const clearPersistedData = useCallback(() => { + try { + const storage = getStorage(); + storage.removeItem(storageKey); + } catch (error) { + console.error(`Error clearing form state from ${storageType} storage:`, error); + } + }, [storageKey, getStorage, storageType]); + + // Return the methods with our added functions + return { + ...methods, + clearPersistedData, + saveCurrentValues, + loadSavedValues, + }; +} diff --git a/internal/ui/src/index.ts b/internal/ui/src/index.ts index 2e5490d656..34d159ee50 100644 --- a/internal/ui/src/index.ts +++ b/internal/ui/src/index.ts @@ -1,7 +1,11 @@ -export * from "./components/button"; +export * from "./components/badge"; +export * from "./components/buttons/button"; +export * from "./components/buttons/copy-button"; +export * from "./components/buttons/keyboard-button"; +export * from "./components/buttons/refresh-button"; +export * from "./components/buttons/visible-button"; export * from "./components/card"; export * from "./components/code"; -export * from "./components/copy-button"; export * from "./components/logs/control-cloud"; export * from "./validation/utils/transform-structured-output-filter-format"; export * from "./components/date-time/date-time"; @@ -11,14 +15,11 @@ export * from "./components/dialog/navigable-dialog"; export * from "./components/empty"; export * from "./components/form"; export * from "./components/id"; -export * from "./components/inline-link"; export * from "./components/info-tooltip"; +export * from "./components/inline-link"; export * from "./components/loading"; export * from "./components/settings-card"; export * from "./components/timestamp-info"; export * from "./components/tooltip"; -export * from "./components/keyboard-button"; export * from "./components/separator"; export * from "./components/toaster"; -export * from "./components/visible-button"; -export * from "./components/badge"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33de7d43bc..b785b3770b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -965,6 +965,9 @@ importers: specifier: 3.23.8 version: 3.23.8 devDependencies: + '@testing-library/react': + specifier: ^16.2.0 + version: 16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@types/node': specifier: ^20.14.9 version: 20.14.9