diff --git a/web/.type-coverage-baseline.yaml b/web/.type-coverage-baseline.yaml index a62a9742d4..af2221be7c 100644 --- a/web/.type-coverage-baseline.yaml +++ b/web/.type-coverage-baseline.yaml @@ -8,7 +8,7 @@ # `ods type-coverage typescript --update`. # # Generated file: do not edit by hand. -total: 98.8 +total: 98.9 packages: .: 100 src: 99.4 @@ -21,15 +21,15 @@ packages: src/app/components: 100 src/app/config: 100 src/app/connector: 100 - src/app/craft: 99.3 + src/app/craft: 99.4 src/app/ee: 95.6 src/app/federated: 97 src/app/mcp: 99.5 src/app/nrf: 99.2 src/app/oauth-config: 100 src/app/oauth-provider: 100 - src/components: 97.7 - src/components/admin: 96.1 + src/components: 97.6 + src/components/admin: 95.8 src/components/auth: 89.3 src/components/chat: 97.2 src/components/context: 100 @@ -42,7 +42,7 @@ packages: src/components/oauth: 89.5 src/components/search: 99.5 src/components/standardAnswers: 100 - src/components/tools: 99.7 + src/components/tools: 100 src/components/tooltip: 100 src/components/ui: 99.9 src/components/voice: 100 @@ -69,7 +69,7 @@ packages: src/lib/chat: 98.6 src/lib/connectors: 99 src/lib/constants: 100 - src/lib/credentials: 98.3 + src/lib/credentials: 98.4 src/lib/extension: 100 src/lib/externalApps: 100 src/lib/hierarchy: 100 @@ -91,10 +91,11 @@ packages: src/lib/sidebar: 100 src/lib/skills: 97.4 src/lib/sso: 99.3 + src/lib/tiers: 100 src/lib/tools: 98.1 src/lib/tracing: 96.9 src/lib/usage: 97.9 - src/lib/users: 99 + src/lib/users: 99.1 src/lib/voice: 99.3 src/lib/webSearch: 97.4 src/providers: 99.5 @@ -130,5 +131,5 @@ packages: src/sections/skills: 99.8 src/sections/usage: 99.6 src/views: 99.4 - src/views/admin: 98.4 + src/views/admin: 98.5 types: 100 diff --git a/web/lib/opal/src/components/README.md b/web/lib/opal/src/components/README.md index 24e15cdb95..2e6c6a3173 100644 --- a/web/lib/opal/src/components/README.md +++ b/web/lib/opal/src/components/README.md @@ -22,7 +22,7 @@ The barrel file at `index.ts` re-exports each component and its prop types. Each | [OpenButton](./buttons/open-button/) | Trigger button with rotating chevron for popovers | [README](./buttons/open-button/README.md) | | [Tag](./tag/) | Small colored label for status/category metadata | [README](./tag/README.md) | | [InputDateRangePicker](./inputs/chrono/input-date-range-picker/) | Preset date-range strip with a custom-range calendar popover | [README](./inputs/chrono/input-date-range-picker/README.md) | -| [InputFile](./inputs/input-file/) | Text field that doubles as a file picker | [README](./inputs/input-file/README.md) | +| [InputUpload](./inputs/input-upload/) | Upload control in the input chrome: pick, drop or paste | [README](./inputs/input-upload/README.md) | | [InputAvatar](./inputs/input-avatar/) | Avatar frame with primary input chrome | [README](./inputs/input-avatar/README.md) | | [InputImage](./inputs/input-image/) | Circular image dropzone with edit overlay | [README](./inputs/input-image/README.md) | | [InputKeyValue](./inputs/input-key-value/) | Key/value pair editor with validation | [README](./inputs/input-key-value/README.md) | diff --git a/web/lib/opal/src/components/index.ts b/web/lib/opal/src/components/index.ts index 42125f159c..dae6f6a54b 100644 --- a/web/lib/opal/src/components/index.ts +++ b/web/lib/opal/src/components/index.ts @@ -91,11 +91,13 @@ export { type InputNumberProps, } from "@opal/components/inputs/input-number/components"; -/* InputFile */ +/* InputUpload */ export { - default as InputFile, - type InputFileProps, -} from "@opal/components/inputs/input-file/components"; + default as InputUpload, + type InputUploadProps, + type InputUploadTextProps, + type InputUploadFileProps, +} from "@opal/components/inputs/input-upload/components"; /* InputList */ export { diff --git a/web/lib/opal/src/components/inputs/input-file/README.md b/web/lib/opal/src/components/inputs/input-file/README.md deleted file mode 100644 index 60783c3efa..0000000000 --- a/web/lib/opal/src/components/inputs/input-file/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# InputFile - -**Import:** `import { InputFile, type InputFileProps } from "@opal/components";` - -A text field that doubles as a file picker: type a value directly, or attach a file via the -paperclip action and the field displays the file's name and carries its text content. Clearing -returns to typed mode. Built on `InputTypeIn`; the attach/clear actions are `Button`s in the -right slot. - -## Props - -Extends `InputTypeInProps` minus the props the component owns itself — -`type`, `rightChildren`, `value`, `onChange`, `readOnly`, `clearButton` — -plus: - -| Prop | Type | Description | -| -------------------- | ------------------------------- | ------------------------------------------------------ | -| `setValue` | `(value: string) => void` | Receives the typed text or the selected file's content | -| `onValueSet` | callback | Fires after a value lands | -| `accept` | `string` | File-picker accept filter | -| `maxSizeKb` | `number` | Reject files above this size | -| `onFileSizeExceeded` | `({ file, maxSizeKb }) => void` | Called when a file is rejected for size | - -The attach/clear aria labels come from `OpalStrings` (`attachFile` / `clearFile`). diff --git a/web/lib/opal/src/components/inputs/input-file/components.tsx b/web/lib/opal/src/components/inputs/input-file/components.tsx deleted file mode 100644 index be1ec83566..0000000000 --- a/web/lib/opal/src/components/inputs/input-file/components.tsx +++ /dev/null @@ -1,181 +0,0 @@ -"use client"; - -import React, { useRef, useState } from "react"; -import InputTypeIn, { - type InputTypeInProps, -} from "@opal/components/inputs/texts/input-type-in/components"; -import { Button } from "@opal/components/buttons/button/components"; -import { SvgPaperclip, SvgX } from "@opal/icons"; -import { noProp } from "@opal/utils"; -import { useOpalStrings } from "@opal/strings"; - -export interface InputFileProps extends Omit< - InputTypeInProps, - "type" | "rightChildren" | "value" | "onChange" | "readOnly" | "clearButton" -> { - /** - * Whether the input is disabled. - */ - disabled?: boolean; - /** - * Whether the input has an error. - */ - error?: boolean; - // Receives the extracted file content (text) or pasted value - setValue: (value: string) => void; - // Called when a value is committed via file selection or paste (not on each keystroke) - onValueSet?: (value: string, source: "file" | "paste") => void; - // HTML accept attribute e.g. "application/json" or ".txt,.md" - accept?: string; - // Maximum allowed file size in kilobytes. If exceeded, file is rejected. - maxSizeKb?: number; - // Optional callback when the selected file exceeds max size - onFileSizeExceeded?: (args: { file: File; maxSizeKb: number }) => void; -} - -export default function InputFile({ - setValue, - onValueSet, - accept, - maxSizeKb, - onFileSizeExceeded, - disabled, - error, - variant, - placeholder, - ...rest -}: InputFileProps) { - const strings = useOpalStrings(); - const [displayValue, setDisplayValue] = useState(""); - const [selectedFileName, setSelectedFileName] = useState(null); - const [isFileMode, setIsFileMode] = useState(false); - const fileInputRef = useRef(null); - - // Derive disabled/readOnly state from either the boolean props or the variant - const isDisabled = disabled || variant === "disabled"; - const isReadOnly = variant === "readOnly"; - const isNonEditable = isDisabled || isReadOnly; - - function openFilePicker() { - if (isNonEditable) return; - fileInputRef.current?.click(); - } - - function handleFileChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; - - // Enforce file size limit if provided - if (typeof maxSizeKb === "number" && maxSizeKb >= 0) { - const maxBytes = maxSizeKb * 1024; - if (file.size > maxBytes) { - onFileSizeExceeded?.({ file, maxSizeKb }); - // Reset file input to allow re-selecting the same file - e.target.value = ""; - return; - } - } - - const reader = new FileReader(); - reader.onload = () => { - const textContent = - typeof reader.result === "string" ? reader.result : ""; - setValue(textContent); - setSelectedFileName(file.name); - setDisplayValue(file.name); - setIsFileMode(true); - onValueSet?.(textContent, "file"); - }; - reader.onerror = () => { - // Reset state on error - setSelectedFileName(null); - setDisplayValue(""); - setIsFileMode(false); - setValue(""); - }; - reader.readAsText(file); - // clear the input value to allow re-selecting the same file if needed - e.target.value = ""; - } - - function handleClear() { - setSelectedFileName(null); - setDisplayValue(""); - setIsFileMode(false); - setValue(""); - } - - function handleChangeWhenTyping(e: React.ChangeEvent) { - if (isFileMode) return; // ignore typing when file-mode is active - const next = e.target.value; - setDisplayValue(next); - setValue(next); - } - - function handlePaste(e: React.ClipboardEvent) { - // Don't allow paste when non-editable - if (isNonEditable) return; - // Switch to editable mode and use pasted text as the value - const pastedText = e.clipboardData.getData("text"); - if (!pastedText) return; - e.preventDefault(); - setIsFileMode(false); - setSelectedFileName(null); - setDisplayValue(pastedText); - setValue(pastedText); - onValueSet?.(pastedText, "paste"); - } - - const rightChildren = isFileMode ? ( - + {hasValue && !isNonEditable && drag === null && ( +