mirror of
https://github.com/run-llama/chat-ui.git
synced 2026-06-30 21:27:55 -04:00
feat: move fileuploader from CL and able to upload multiple files (#11)
--------- Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@llamaindex/chat-ui': patch
|
||||
---
|
||||
|
||||
Add progress bar for agent events
|
||||
@@ -10,25 +10,33 @@ jobs:
|
||||
name: Create Changeset PR
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
uses: pnpm/action-setup@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
version: 8
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Get changeset status
|
||||
id: get-changeset-status
|
||||
run: |
|
||||
pnpm changeset status --output .changeset/status.json
|
||||
new_version=$(jq -r '.releases[0].newVersion' < .changeset/status.json)
|
||||
rm -v .changeset/status.json
|
||||
echo "new-version=${new_version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Release Pull Request
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
commit: Release ${{ steps.get-changeset-status.outputs.new-version }}
|
||||
title: Release ${{ steps.get-changeset-status.outputs.new-version }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.453.0",
|
||||
|
||||
@@ -48,9 +48,17 @@ export type EventData = {
|
||||
title: string
|
||||
}
|
||||
|
||||
export type ProgressData = {
|
||||
id: string
|
||||
total: number
|
||||
current: number
|
||||
}
|
||||
|
||||
export type AgentEventData = {
|
||||
agent: string
|
||||
text: string
|
||||
type: 'text' | 'progress'
|
||||
data?: ProgressData
|
||||
}
|
||||
|
||||
export type SuggestedQuestionsData = string[]
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Loader2, Paperclip } from 'lucide-react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import { createContext, useContext } from 'react'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Button, buttonVariants } from '../ui/button'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { Textarea } from '../ui/textarea'
|
||||
import { FileUploader } from '../widget/file-uploader'
|
||||
import { useChatUI } from './chat.context'
|
||||
import { Message } from './chat.interface'
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['png', 'jpg', 'jpeg', 'csv', 'pdf', 'txt', 'docx']
|
||||
|
||||
interface ChatInputProps extends React.PropsWithChildren {
|
||||
className?: string
|
||||
resetUploadedFiles?: () => void
|
||||
@@ -25,12 +26,14 @@ interface ChatInputFieldProps {
|
||||
|
||||
interface ChatInputUploadProps {
|
||||
className?: string
|
||||
inputId?: string
|
||||
onUpload?: (file: File) => Promise<void>
|
||||
onUpload?: (file: File) => Promise<void> | undefined
|
||||
allowedExtensions?: string[]
|
||||
multiple?: boolean
|
||||
}
|
||||
|
||||
interface ChatInputSubmitProps extends React.PropsWithChildren {
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface ChatInputContext {
|
||||
@@ -144,60 +147,35 @@ function ChatInputField(props: ChatInputFieldProps) {
|
||||
|
||||
function ChatInputUpload(props: ChatInputUploadProps) {
|
||||
const { requestData, setRequestData, isLoading } = useChatUI()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const inputId = props.inputId || 'fileInput'
|
||||
|
||||
const resetInput = () => {
|
||||
const fileInput = document.getElementById(inputId) as HTMLInputElement
|
||||
fileInput.value = ''
|
||||
}
|
||||
|
||||
const onFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setUploading(true)
|
||||
const onFileUpload = async (file: File) => {
|
||||
if (props.onUpload) {
|
||||
await props.onUpload(file)
|
||||
} else {
|
||||
setRequestData({ ...(requestData || {}), file })
|
||||
}
|
||||
resetInput()
|
||||
setUploading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="self-stretch">
|
||||
<input
|
||||
id={inputId}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={onFileChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'secondary', size: 'icon' }),
|
||||
'cursor-pointer',
|
||||
uploading && 'opacity-50',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Paperclip className="h-4 w-4 -rotate-45" />
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<FileUploader
|
||||
onFileUpload={onFileUpload}
|
||||
config={{
|
||||
disabled: isLoading,
|
||||
allowedExtensions: props.allowedExtensions ?? ALLOWED_EXTENSIONS,
|
||||
multiple: props.multiple ?? true,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatInputSubmit(props: ChatInputSubmitProps) {
|
||||
const { isDisabled } = useChatInput()
|
||||
return (
|
||||
<Button type="submit" disabled={isDisabled} className={cn(props.className)}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={props.disabled ?? isDisabled}
|
||||
className={cn(props.className)}
|
||||
>
|
||||
{props.children ?? 'Send message'}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||
import * as React from 'react'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-secondary relative h-4 w-full overflow-hidden rounded-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
DrawerTrigger,
|
||||
} from '../ui/drawer'
|
||||
import { Markdown } from './markdown'
|
||||
import { AgentEventData } from '../chat/annotation'
|
||||
import { AgentEventData, ProgressData } from '../chat/annotation'
|
||||
import { Progress } from '../ui/progress'
|
||||
|
||||
const AgentIcons: Record<string, LucideIcon> = {
|
||||
bot: icons.Bot,
|
||||
@@ -22,10 +23,19 @@ const AgentIcons: Record<string, LucideIcon> = {
|
||||
publisher: icons.BookCheck,
|
||||
}
|
||||
|
||||
type StepText = {
|
||||
text: string
|
||||
}
|
||||
|
||||
type StepProgress = {
|
||||
text: string
|
||||
progress: ProgressData
|
||||
}
|
||||
|
||||
type MergedEvent = {
|
||||
agent: string
|
||||
texts: string[]
|
||||
icon: LucideIcon
|
||||
steps: (StepText | StepProgress)[]
|
||||
}
|
||||
|
||||
export function ChatAgentEvents({
|
||||
@@ -54,6 +64,51 @@ export function ChatAgentEvents({
|
||||
|
||||
const MAX_TEXT_LENGTH = 150
|
||||
|
||||
function TextContent({ agent, step }: { agent: string; step: StepText }) {
|
||||
const { displayText, showMore } = useMemo(
|
||||
() => ({
|
||||
displayText: step.text.slice(0, MAX_TEXT_LENGTH),
|
||||
showMore: step.text.length > MAX_TEXT_LENGTH,
|
||||
}),
|
||||
[step.text]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="whitespace-break-spaces">
|
||||
{!showMore && <span>{step.text}</span>}
|
||||
{showMore && (
|
||||
<div>
|
||||
<span>{displayText}...</span>
|
||||
<AgentEventDialog content={step.text} title={`Agent "${agent}"`}>
|
||||
<span className="ml-2 cursor-pointer font-semibold underline">
|
||||
Show more
|
||||
</span>
|
||||
</AgentEventDialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressContent({ step }: { step: StepProgress }) {
|
||||
const progressValue =
|
||||
step.progress.total !== 0
|
||||
? Math.round(((step.progress.current + 1) / step.progress.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
{step.text && (
|
||||
<p className="text-muted-foreground text-sm">{step.text}</p>
|
||||
)}
|
||||
<Progress value={progressValue} className="h-2 w-full" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Processing {step.progress.current + 1} of {step.progress.total} steps...
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentEventContent({
|
||||
event,
|
||||
isLast,
|
||||
@@ -63,8 +118,19 @@ function AgentEventContent({
|
||||
isLast: boolean
|
||||
isFinished: boolean
|
||||
}) {
|
||||
const { agent, texts } = event
|
||||
const { agent, steps } = event
|
||||
const AgentIcon = event.icon
|
||||
const textSteps = steps.filter(step => !('progress' in step))
|
||||
const progressSteps = steps.filter(
|
||||
step => 'progress' in step
|
||||
) as StepProgress[]
|
||||
// We only show progress at the last step
|
||||
// TODO: once we support steps that work in parallel, we need to update this
|
||||
const lastProgressStep =
|
||||
progressSteps.length > 0
|
||||
? progressSteps[progressSteps.length - 1]
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="fadein-agent flex items-center gap-4 border-b pb-4">
|
||||
<div className="flex w-[100px] flex-col items-center gap-2">
|
||||
@@ -81,26 +147,20 @@ function AgentEventContent({
|
||||
</div>
|
||||
<span className="font-bold">{agent}</span>
|
||||
</div>
|
||||
<ul className="flex-1 list-decimal space-y-2">
|
||||
{texts.map((text, index) => (
|
||||
<li className="whitespace-break-spaces" key={index}>
|
||||
{text.length <= MAX_TEXT_LENGTH && <span>{text}</span>}
|
||||
{text.length > MAX_TEXT_LENGTH && (
|
||||
<div>
|
||||
<span>{text.slice(0, MAX_TEXT_LENGTH)}...</span>
|
||||
<AgentEventDialog
|
||||
content={text}
|
||||
title={`Agent "${agent}" - Step: ${index + 1}`}
|
||||
>
|
||||
<span className="ml-2 cursor-pointer font-semibold underline">
|
||||
Show more
|
||||
</span>
|
||||
</AgentEventDialog>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{textSteps.length > 0 && (
|
||||
<div className="flex-1">
|
||||
<ul className="list-decimal space-y-2">
|
||||
{textSteps.map((step, index) => (
|
||||
<li key={index}>
|
||||
<TextContent agent={agent} step={step} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{lastProgressStep && !isFinished && (
|
||||
<ProgressContent step={lastProgressStep} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -138,15 +198,22 @@ function mergeAdjacentEvents(events: AgentEventData[]): MergedEvent[] {
|
||||
for (const event of events) {
|
||||
const lastMergedEvent = mergedEvents[mergedEvents.length - 1]
|
||||
|
||||
const eventStep: StepText | StepProgress = event.data
|
||||
? ({
|
||||
text: event.text,
|
||||
progress: event.data,
|
||||
} as StepProgress)
|
||||
: ({
|
||||
text: event.text,
|
||||
} as StepText)
|
||||
|
||||
if (lastMergedEvent && lastMergedEvent.agent === event.agent) {
|
||||
// If the last event in mergedEvents has the same non-null agent, add the title to it
|
||||
lastMergedEvent.texts.push(event.text)
|
||||
lastMergedEvent.steps.push(eventStep)
|
||||
} else {
|
||||
// Otherwise, create a new merged event
|
||||
mergedEvents.push({
|
||||
agent: event.agent,
|
||||
texts: [event.text],
|
||||
icon: AgentIcons[event.agent] ?? icons.Bot,
|
||||
steps: [eventStep],
|
||||
icon: AgentIcons[event.agent.toLowerCase()] ?? icons.Bot,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ function SourceInfo({ node, index }: { node?: SourceNode; index: number }) {
|
||||
className="hover:bg-primary hover:text-white"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-[400px]">
|
||||
<HoverCardContent className="w-[400px] bg-white p-4">
|
||||
<NodeInfo nodeInfo={node} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import { Loader2, Paperclip } from 'lucide-react'
|
||||
import { ChangeEvent, useState } from 'react'
|
||||
import { buttonVariants } from '../ui/button'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
export interface FileUploaderProps {
|
||||
config?: {
|
||||
inputId?: string
|
||||
fileSizeLimit?: number
|
||||
allowedExtensions?: string[]
|
||||
checkExtension?: (extension: string) => string | null
|
||||
disabled: boolean
|
||||
multiple?: boolean
|
||||
}
|
||||
onFileUpload: (file: File) => Promise<void>
|
||||
onFileError?: (errMsg: string) => void
|
||||
}
|
||||
|
||||
const DEFAULT_INPUT_ID = 'fileInput'
|
||||
const DEFAULT_FILE_SIZE_LIMIT = 1024 * 1024 * 50 // 50 MB
|
||||
|
||||
export function FileUploader({
|
||||
config,
|
||||
onFileUpload,
|
||||
onFileError,
|
||||
}: FileUploaderProps) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [remainingFiles, setRemainingFiles] = useState<number>(0)
|
||||
|
||||
const inputId = config?.inputId || DEFAULT_INPUT_ID
|
||||
const fileSizeLimit = config?.fileSizeLimit || DEFAULT_FILE_SIZE_LIMIT
|
||||
const allowedExtensions = config?.allowedExtensions
|
||||
const defaultCheckExtension = (extension: string) => {
|
||||
if (allowedExtensions && !allowedExtensions.includes(extension)) {
|
||||
return `Invalid file type. Please select a file with one of these formats: ${allowedExtensions.join(
|
||||
','
|
||||
)}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
const checkExtension = config?.checkExtension ?? defaultCheckExtension
|
||||
|
||||
const isFileSizeExceeded = (file: File) => {
|
||||
return file.size > fileSizeLimit
|
||||
}
|
||||
|
||||
const resetInput = () => {
|
||||
const fileInput = document.getElementById(inputId) as HTMLInputElement
|
||||
fileInput.value = ''
|
||||
}
|
||||
|
||||
const onFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (!files.length) return
|
||||
|
||||
setUploading(true)
|
||||
|
||||
await handleUpload(files)
|
||||
|
||||
resetInput()
|
||||
setUploading(false)
|
||||
}
|
||||
|
||||
const handleUpload = async (files: File[]) => {
|
||||
const onFileUploadError = onFileError || window.alert
|
||||
// Validate files
|
||||
// If multiple files with image or multiple images
|
||||
if (
|
||||
files.length > 1 &&
|
||||
files.some(file => file.type.startsWith('image/'))
|
||||
) {
|
||||
onFileUploadError('Multiple files with image are not supported')
|
||||
return
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const fileExtension = file.name.split('.').pop() || ''
|
||||
const extensionFileError = checkExtension(fileExtension)
|
||||
if (extensionFileError) {
|
||||
onFileUploadError(extensionFileError)
|
||||
return
|
||||
}
|
||||
|
||||
if (isFileSizeExceeded(file)) {
|
||||
onFileUploadError(
|
||||
`File size exceeded. Limit is ${fileSizeLimit / 1024 / 1024} MB`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setRemainingFiles(files.length)
|
||||
for (const file of files) {
|
||||
await onFileUpload(file)
|
||||
setRemainingFiles(prev => prev - 1)
|
||||
}
|
||||
setRemainingFiles(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="self-stretch">
|
||||
<input
|
||||
type="file"
|
||||
id={inputId}
|
||||
style={{ display: 'none' }}
|
||||
onChange={onFileChange}
|
||||
accept={allowedExtensions?.join(',')}
|
||||
disabled={config?.disabled || uploading}
|
||||
multiple={config?.multiple}
|
||||
/>
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'secondary', size: 'icon' }),
|
||||
'relative cursor-pointer',
|
||||
uploading && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="absolute h-6 w-6 animate-spin" />
|
||||
{remainingFiles > 0 && (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-xs">
|
||||
{remainingFiles}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Paperclip className="h-4 w-4 -rotate-45" />
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,3 +11,4 @@ export { SuggestedQuestions } from './widget/suggested-questions'
|
||||
export { StarterQuestions } from './widget/starter-questions'
|
||||
export { DocumentPreview } from './widget/document-preview'
|
||||
export { ImagePreview } from './widget/image-preview'
|
||||
export { FileUploader } from './widget/file-uploader'
|
||||
|
||||
Vendored
+2
@@ -68,6 +68,8 @@ module.exports = {
|
||||
'jsx-a11y/click-events-have-key-events': 'off',
|
||||
'jsx-a11y/no-static-element-interactions': 'off',
|
||||
'jsx-a11y/anchor-is-valid': 'off',
|
||||
'no-await-in-loop': 'off',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
||||
Generated
+26
@@ -114,6 +114,9 @@ importers:
|
||||
'@radix-ui/react-icons':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0(react@18.2.0)
|
||||
'@radix-ui/react-progress':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.2(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -1214,6 +1217,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-progress@1.1.0':
|
||||
resolution: {integrity: sha512-aSzvnYpP725CROcxAOEBVZZSIQVQdHgBr2QQFKySsaD14u8dNT0batuXI+AAGDdAHfXH8rbnHmjYFqVJ21KkRg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.0':
|
||||
resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==}
|
||||
peerDependencies:
|
||||
@@ -7026,6 +7042,16 @@ snapshots:
|
||||
'@types/react': 18.2.61
|
||||
'@types/react-dom': 18.2.19
|
||||
|
||||
'@radix-ui/react-progress@1.1.0(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-context': 1.1.0(@types/react@18.2.61)(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.2.61
|
||||
'@types/react-dom': 18.2.19
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.2.19)(@types/react@18.2.61)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.0
|
||||
|
||||
Reference in New Issue
Block a user