diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx
index dc6518e9..f2e43af0 100644
--- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx
+++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx
@@ -17,7 +17,6 @@ import {
Fragment,
useCallback,
useEffect,
- useId,
useLayoutEffect,
useMemo,
useRef,
@@ -29,6 +28,19 @@ import CameraScannerModal from "@/components/mobile/CameraScannerModal";
import { ModelViewer } from "@/components/3d/ModelViewer";
import { ModelPreviewPanel } from "@/components/platform/ModelPreviewPanel";
import Button from "@/components/ui/Button";
+import {
+ InlineSelect,
+ InlineSuggestionInput,
+} from "./components/InlineControls";
+import {
+ BasicField,
+ CreateFieldShell,
+ FieldLabel,
+} from "./components/FormControls";
+import {
+ SectionCard,
+ WorkspacePreviewEmptyState,
+} from "./components/WorkspaceShell";
import {
calculateMenuPosition,
ContextMenu,
@@ -8573,807 +8585,6 @@ function createInitialDocumentWorkspaceApprovedAt(
return approvedAt;
}
-function FieldLabel({
- label,
- required,
-}: {
- label: string;
- required?: boolean;
-}) {
- return (
-
- );
-}
-
-function CreateFieldShell({ children }: { children: ReactNode }) {
- return (
-
- {children}
-
- );
-}
-
-type FloatingPanelPosition = {
- left: number;
- top: number;
- width: number;
- maxWidth: number;
- maxHeight: number;
-};
-
-function useFloatingPanelPosition(
- isOpen: boolean,
- anchorRef: RefObject,
- gap = 4,
-) {
- const [position, setPosition] = useState(null);
-
- const updatePosition = useCallback(() => {
- const anchor = anchorRef.current;
- if (!anchor || typeof window === "undefined") return;
-
- const rect = anchor.getBoundingClientRect();
- const viewportPadding = 12;
- const left = Math.min(
- Math.max(viewportPadding, rect.left),
- Math.max(viewportPadding, window.innerWidth - rect.width - viewportPadding),
- );
- const availableBelow = window.innerHeight - rect.bottom - gap - viewportPadding;
-
- setPosition({
- left,
- top: rect.bottom + gap,
- width: rect.width,
- maxWidth: Math.max(rect.width, window.innerWidth - left - viewportPadding),
- maxHeight: Math.max(64, availableBelow),
- });
- }, [anchorRef, gap]);
-
- useLayoutEffect(() => {
- if (!isOpen) {
- setPosition(null);
- return;
- }
-
- updatePosition();
- window.addEventListener("resize", updatePosition);
- window.addEventListener("scroll", updatePosition, true);
-
- return () => {
- window.removeEventListener("resize", updatePosition);
- window.removeEventListener("scroll", updatePosition, true);
- };
- }, [isOpen, updatePosition]);
-
- return position;
-}
-
-function InlineSuggestionInput({
- ariaLabel,
- dataTestId,
- value,
- options,
- placeholder,
- readOnly = false,
- wrapperClassName = "relative",
- inputClassName,
- panelClassName = "absolute left-0 right-0 top-[calc(100%+4px)] z-50 max-h-48 overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md",
- optionClassName = "flex w-full items-center rounded-apple-md px-2.5 py-1.5 text-left text-xs transition",
- showChevron = true,
- filterOptions = false,
- closeOnChange,
- onPaste,
- onChange,
- onSelect,
- onCommit,
-}: {
- ariaLabel: string;
- dataTestId?: string;
- value: string;
- options?: readonly string[];
- placeholder?: string;
- readOnly?: boolean;
- wrapperClassName?: string;
- inputClassName?: string;
- panelClassName?: string;
- optionClassName?: string;
- showChevron?: boolean;
- filterOptions?: boolean;
- closeOnChange?: (value: string) => boolean;
- onPaste?: (event: ClipboardEvent) => void;
- onChange: (value: string) => void;
- onSelect?: (value: string) => void;
- onCommit?: () => void;
-}) {
- const suggestionListId = useId();
- const wrapperRef = useRef(null);
- const panelRef = useRef(null);
- const [isOpen, setIsOpen] = useState(false);
- const [hasTypedQuery, setHasTypedQuery] = useState(false);
- const previousValueRef = useRef(value);
- const normalizedOptions = getUniqueNonEmptyStrings(options ?? []);
- const keyword = value.trim().toLowerCase();
- const visibleOptions = normalizedOptions.filter((option) =>
- filterOptions && hasTypedQuery && keyword
- ? option.toLowerCase().includes(keyword)
- : true,
- );
- const hasOptions = !readOnly && normalizedOptions.length > 0;
- const floatingPosition = useFloatingPanelPosition(isOpen && hasOptions, wrapperRef);
-
- useEffect(() => {
- if (previousValueRef.current && !value) {
- setIsOpen(false);
- }
- previousValueRef.current = value;
- }, [value]);
-
- useEffect(() => {
- if (!isOpen) return;
-
- function handleDocumentMouseDown(event: MouseEvent) {
- const target = event.target as Node;
- if (
- wrapperRef.current?.contains(target) ||
- panelRef.current?.contains(target)
- ) {
- return;
- }
- setIsOpen(false);
- }
-
- document.addEventListener("mousedown", handleDocumentMouseDown);
- return () =>
- document.removeEventListener("mousedown", handleDocumentMouseDown);
- }, [isOpen]);
-
- const closeLater = useCallback(() => {
- setIsOpen(false);
- }, []);
-
- const handleSelect = useCallback(
- (option: string) => {
- onChange(option);
- onSelect?.(option);
- setIsOpen(false);
- },
- [onChange, onSelect],
- );
-
- return (
-
-
{
- const nextValue = event.target.value;
- setHasTypedQuery(true);
- onChange(nextValue);
- if (hasOptions) {
- setIsOpen(closeOnChange?.(nextValue) ? false : true);
- }
- }}
- onPaste={onPaste}
- onFocus={() => {
- setHasTypedQuery(false);
- if (hasOptions) setIsOpen(true);
- }}
- onBlur={() => {
- setHasTypedQuery(false);
- closeLater();
- onCommit?.();
- }}
- onKeyDown={(event) => {
- if (event.key === "Escape") {
- setHasTypedQuery(false);
- setIsOpen(false);
- return;
- }
- if (event.key === "Enter") {
- event.preventDefault();
- onCommit?.();
- setHasTypedQuery(false);
- setIsOpen(false);
- }
- }}
- placeholder={placeholder}
- readOnly={readOnly}
- className={inputClassName}
- />
- {hasOptions && showChevron ? (
-
- ) : null}
- {hasOptions && isOpen && floatingPosition
- ? createPortal(
-
- {visibleOptions.length > 0 ? (
- visibleOptions.map((option) => (
-
- ))
- ) : (
-
- 没有匹配项
-
- )}
-
,
- document.body,
- )
- : null}
-
- );
-}
-
-type InlineSelectOption =
- | string
- | {
- value: string;
- label?: string;
- disabled?: boolean;
- };
-
-function normalizeInlineSelectOptions(
- options: readonly InlineSelectOption[] | undefined,
-): { value: string; label: string; disabled?: boolean }[] {
- return (options ?? []).map((option) =>
- typeof option === "string"
- ? { value: option, label: option }
- : { value: option.value, label: option.label ?? option.value, disabled: option.disabled },
- );
-}
-
-function InlineSelect({
- ariaLabel,
- dataTestId,
- value,
- options,
- placeholder,
- readOnly = false,
- wrapperClassName = "relative",
- buttonClassName,
- panelClassName = "absolute left-0 right-0 top-[calc(100%+4px)] z-50 max-h-48 overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md",
- optionClassName = "flex w-full items-center rounded-apple-md px-2.5 py-1.5 text-left text-xs transition",
- includeEmptyOption,
- emptyOptionDisabled = false,
- showIndicatorWhenReadOnly = false,
- onChange,
-}: {
- ariaLabel: string;
- dataTestId?: string;
- value: string;
- options?: readonly InlineSelectOption[];
- placeholder?: string;
- readOnly?: boolean;
- wrapperClassName?: string;
- buttonClassName?: string;
- panelClassName?: string;
- optionClassName?: string;
- includeEmptyOption?: boolean;
- emptyOptionDisabled?: boolean;
- showIndicatorWhenReadOnly?: boolean;
- onChange: (value: string) => void;
-}) {
- const listId = useId();
- const containerRef = useRef(null);
- const panelRef = useRef(null);
- const [isOpen, setIsOpen] = useState(false);
- const normalizedOptions = normalizeInlineSelectOptions(options);
- const shouldIncludeEmptyOption = includeEmptyOption ?? false;
- const selectOptions = shouldIncludeEmptyOption
- ? [
- {
- value: "",
- label: placeholder ?? "",
- disabled: emptyOptionDisabled,
- },
- ...normalizedOptions,
- ]
- : normalizedOptions;
- const selectedOption = selectOptions.find((option) => option.value === value);
- const displayValue = selectedOption?.label ?? (value || placeholder || "");
- const hasOptions = !readOnly && selectOptions.length > 0;
- const shouldShowIndicator =
- hasOptions || (readOnly && showIndicatorWhenReadOnly && selectOptions.length > 0);
- const floatingPosition = useFloatingPanelPosition(isOpen && hasOptions, containerRef);
-
- useEffect(() => {
- if (!isOpen) return;
-
- function handleDocumentMouseDown(event: MouseEvent) {
- const target = event.target as Node;
- if (
- containerRef.current?.contains(target) ||
- panelRef.current?.contains(target)
- ) {
- return;
- }
- setIsOpen(false);
- }
-
- document.addEventListener("mousedown", handleDocumentMouseDown);
- return () =>
- document.removeEventListener("mousedown", handleDocumentMouseDown);
- }, [isOpen]);
-
- return (
-
-
- {hasOptions && isOpen && floatingPosition
- ? createPortal(
-
- {selectOptions.map((option) => (
-
- ))}
-
,
- document.body,
- )
- : null}
-
- );
-}
-
-function SearchableInlineSelect({
- ariaLabel,
- dataTestId,
- value,
- options,
- placeholder,
- readOnly = false,
- wrapperClassName = "relative",
- inputClassName,
- panelClassName = "absolute left-0 right-0 top-[calc(100%+4px)] z-50 max-h-48 overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md",
- optionClassName = "flex w-full items-center rounded-apple-md px-2.5 py-1.5 text-left text-xs transition",
- includeEmptyOption,
- emptyOptionDisabled = false,
- onChange,
-}: {
- ariaLabel: string;
- dataTestId?: string;
- value: string;
- options?: readonly InlineSelectOption[];
- placeholder?: string;
- readOnly?: boolean;
- wrapperClassName?: string;
- inputClassName?: string;
- panelClassName?: string;
- optionClassName?: string;
- includeEmptyOption?: boolean;
- emptyOptionDisabled?: boolean;
- onChange: (value: string) => void;
-}) {
- const listId = useId();
- const containerRef = useRef(null);
- const panelRef = useRef(null);
- const inputRef = useRef(null);
- const [isOpen, setIsOpen] = useState(false);
- const [query, setQuery] = useState("");
- const normalizedOptions = normalizeInlineSelectOptions(options);
- const shouldIncludeEmptyOption = includeEmptyOption ?? false;
- const selectOptions = shouldIncludeEmptyOption
- ? [
- {
- value: "",
- label: placeholder ?? "",
- disabled: emptyOptionDisabled,
- },
- ...normalizedOptions,
- ]
- : normalizedOptions;
- const selectedOption = selectOptions.find((option) => option.value === value);
- const displayValue = selectedOption?.label ?? value;
- const normalizedQuery = query.trim().toLowerCase();
- const normalizedDisplayValue = displayValue.trim().toLowerCase();
- const shouldFilterOptions =
- normalizedQuery.length > 0 && normalizedQuery !== normalizedDisplayValue;
- const visibleOptions = selectOptions.filter((option) => {
- if (!shouldFilterOptions) return true;
- return (
- option.label.toLowerCase().includes(normalizedQuery) ||
- option.value.toLowerCase().includes(normalizedQuery)
- );
- });
- const hasOptions = !readOnly && selectOptions.length > 0;
- const floatingPosition = useFloatingPanelPosition(isOpen && hasOptions, containerRef);
-
- useEffect(() => {
- if (isOpen) return;
- setQuery(displayValue);
- }, [displayValue, isOpen]);
-
- useEffect(() => {
- if (!isOpen) return;
-
- function handleDocumentMouseDown(event: MouseEvent) {
- const target = event.target as Node;
- if (
- containerRef.current?.contains(target) ||
- panelRef.current?.contains(target)
- ) {
- return;
- }
- setIsOpen(false);
- }
-
- document.addEventListener("mousedown", handleDocumentMouseDown);
- return () =>
- document.removeEventListener("mousedown", handleDocumentMouseDown);
- }, [isOpen]);
-
- const openPanel = useCallback(() => {
- if (!hasOptions) return;
- setQuery(displayValue);
- setIsOpen(true);
- window.setTimeout(() => inputRef.current?.select(), 0);
- }, [displayValue, hasOptions]);
-
- const handleSelect = useCallback(
- (option: { value: string; label: string; disabled?: boolean }) => {
- if (option.disabled) return;
- onChange(option.value);
- setQuery(option.label);
- setIsOpen(false);
- },
- [onChange],
- );
-
- return (
-
-
{
- if (!hasOptions) return;
- setQuery(event.target.value);
- setIsOpen(true);
- }}
- onKeyDown={(event) => {
- if (event.key === "Escape") {
- setIsOpen(false);
- return;
- }
- if (event.key === "Enter") {
- event.preventDefault();
- const firstEnabledOption = visibleOptions.find(
- (option) => !option.disabled,
- );
- if (firstEnabledOption) {
- handleSelect(firstEnabledOption);
- } else {
- setIsOpen(false);
- }
- }
- }}
- onBlur={() => {
- setQuery(displayValue);
- setIsOpen(false);
- }}
- className={inputClassName}
- />
- {hasOptions ? (
-
- ) : null}
- {hasOptions && isOpen && floatingPosition
- ? createPortal(
-
- {visibleOptions.length > 0 ? (
- visibleOptions.map((option) => (
-
- ))
- ) : (
-
- 没有匹配项
-
- )}
-
,
- document.body,
- )
- : null}
-
- );
-}
-
-function BasicField({
- label,
- required,
- type = "text",
- placeholder,
- options,
- value,
- readOnly = false,
- compact = false,
- searchableSelect = false,
- onChange,
-}: {
- label: string;
- required?: boolean;
- type?: BasicFieldType;
- placeholder?: string;
- options?: readonly string[];
- value: string;
- readOnly?: boolean;
- compact?: boolean;
- searchableSelect?: boolean;
- onChange: (value: string) => void;
-}) {
- if (type === "select") {
- return (
-
-
- {searchableSelect ? (
-
- ) : (
-
- )}
-
- );
- }
-
- if (type === "textarea") {
- return (
-
-
-
- );
- }
-
- return (
-
-
- 0 ? "pr-9" : ""
- } ${
- readOnly
- ? "bg-apple-bg-primary text-apple-label-secondary"
- : "bg-apple-bg-secondary"
- }`}
- />
-
- );
-}
-
function ImagePreviewDialog({
src,
alt,
@@ -9602,30 +8813,6 @@ function SeriesComponentDetailsDialog({
);
}
-function WorkspacePreviewEmptyState({
- title,
- description,
- testId,
-}: {
- title: string;
- description: string;
- testId?: string;
-}) {
- return (
-
-
- {title}
-
-
- {description}
-
-
- );
-}
-
function SpreadsheetPreviewPanel({
preview,
activeSheetIndex,
@@ -9888,70 +9075,6 @@ function UploadField({
);
}
-function SectionCard({
- title,
- description,
- children,
- className = "",
- bodyClassName = "",
- headerActions,
- compact = false,
-}: {
- title?: string;
- description?: string;
- children: React.ReactNode;
- className?: string;
- bodyClassName?: string;
- headerActions?: React.ReactNode;
- compact?: boolean;
-}) {
- const hasHeaderText = Boolean(title || description);
- const hasHeader = hasHeaderText || Boolean(headerActions);
-
- return (
-
- {hasHeader ? (
-
- {hasHeaderText ? (
-
- {title ? (
-
- {title}
-
- ) : null}
- {description ? (
-
- {description}
-
- ) : null}
-
- ) : null}
- {headerActions ? (
-
- {headerActions}
-
- ) : null}
-
- ) : null}
-
{children}
-
- );
-}
-
function readFileAsDataUrl(file: File) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
diff --git a/furniture-3d-system/src/app/intake/non-integrated/components/FormControls.tsx b/furniture-3d-system/src/app/intake/non-integrated/components/FormControls.tsx
new file mode 100644
index 00000000..add8ac6e
--- /dev/null
+++ b/furniture-3d-system/src/app/intake/non-integrated/components/FormControls.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import type { ReactNode } from "react";
+import {
+ InlineSelect,
+ InlineSuggestionInput,
+ SearchableInlineSelect,
+} from "./InlineControls";
+
+type BasicFieldType = "text" | "textarea" | "select";
+
+export function FieldLabel({
+ label,
+ required,
+}: {
+ label: string;
+ required?: boolean;
+}) {
+ return (
+
+ );
+}
+
+export function CreateFieldShell({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function BasicField({
+ label,
+ required,
+ type = "text",
+ placeholder,
+ options,
+ value,
+ readOnly = false,
+ compact = false,
+ searchableSelect = false,
+ onChange,
+}: {
+ label: string;
+ required?: boolean;
+ type?: BasicFieldType;
+ placeholder?: string;
+ options?: readonly string[];
+ value: string;
+ readOnly?: boolean;
+ compact?: boolean;
+ searchableSelect?: boolean;
+ onChange: (value: string) => void;
+}) {
+ if (type === "select") {
+ return (
+
+
+ {searchableSelect ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
+
+ if (type === "textarea") {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ 0 ? "pr-9" : ""
+ } ${
+ readOnly
+ ? "bg-apple-bg-primary text-apple-label-secondary"
+ : "bg-apple-bg-secondary"
+ }`}
+ />
+
+ );
+}
diff --git a/furniture-3d-system/src/app/intake/non-integrated/components/InlineControls.tsx b/furniture-3d-system/src/app/intake/non-integrated/components/InlineControls.tsx
new file mode 100644
index 00000000..d834e546
--- /dev/null
+++ b/furniture-3d-system/src/app/intake/non-integrated/components/InlineControls.tsx
@@ -0,0 +1,678 @@
+"use client";
+
+import type { ClipboardEvent, RefObject } from "react";
+import {
+ useCallback,
+ useEffect,
+ useId,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import { createPortal } from "react-dom";
+import { ChevronDown } from "lucide-react";
+
+function getUniqueNonEmptyStrings(
+ values: ReadonlyArray,
+) {
+ return Array.from(
+ new Set(
+ values
+ .map((value) => value?.trim() ?? "")
+ .filter((value) => value.length > 0),
+ ),
+ );
+}
+
+type FloatingPanelPosition = {
+ left: number;
+ top: number;
+ width: number;
+ maxWidth: number;
+ maxHeight: number;
+};
+
+function useFloatingPanelPosition(
+ isOpen: boolean,
+ anchorRef: RefObject,
+ gap = 4,
+) {
+ const [position, setPosition] = useState(null);
+
+ const updatePosition = useCallback(() => {
+ const anchor = anchorRef.current;
+ if (!anchor || typeof window === "undefined") return;
+
+ const rect = anchor.getBoundingClientRect();
+ const viewportPadding = 12;
+ const left = Math.min(
+ Math.max(viewportPadding, rect.left),
+ Math.max(viewportPadding, window.innerWidth - rect.width - viewportPadding),
+ );
+ const availableBelow = window.innerHeight - rect.bottom - gap - viewportPadding;
+
+ setPosition({
+ left,
+ top: rect.bottom + gap,
+ width: rect.width,
+ maxWidth: Math.max(rect.width, window.innerWidth - left - viewportPadding),
+ maxHeight: Math.max(64, availableBelow),
+ });
+ }, [anchorRef, gap]);
+
+ useLayoutEffect(() => {
+ if (!isOpen) {
+ setPosition(null);
+ return;
+ }
+
+ updatePosition();
+ window.addEventListener("resize", updatePosition);
+ window.addEventListener("scroll", updatePosition, true);
+
+ return () => {
+ window.removeEventListener("resize", updatePosition);
+ window.removeEventListener("scroll", updatePosition, true);
+ };
+ }, [isOpen, updatePosition]);
+
+ return position;
+}
+
+export function InlineSuggestionInput({
+ ariaLabel,
+ dataTestId,
+ value,
+ options,
+ placeholder,
+ readOnly = false,
+ wrapperClassName = "relative",
+ inputClassName,
+ panelClassName = "absolute left-0 right-0 top-[calc(100%+4px)] z-50 max-h-48 overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md",
+ optionClassName = "flex w-full items-center rounded-apple-md px-2.5 py-1.5 text-left text-xs transition",
+ showChevron = true,
+ filterOptions = false,
+ closeOnChange,
+ onPaste,
+ onChange,
+ onSelect,
+ onCommit,
+}: {
+ ariaLabel: string;
+ dataTestId?: string;
+ value: string;
+ options?: readonly string[];
+ placeholder?: string;
+ readOnly?: boolean;
+ wrapperClassName?: string;
+ inputClassName?: string;
+ panelClassName?: string;
+ optionClassName?: string;
+ showChevron?: boolean;
+ filterOptions?: boolean;
+ closeOnChange?: (value: string) => boolean;
+ onPaste?: (event: ClipboardEvent) => void;
+ onChange: (value: string) => void;
+ onSelect?: (value: string) => void;
+ onCommit?: () => void;
+}) {
+ const suggestionListId = useId();
+ const wrapperRef = useRef(null);
+ const panelRef = useRef(null);
+ const [isOpen, setIsOpen] = useState(false);
+ const [hasTypedQuery, setHasTypedQuery] = useState(false);
+ const previousValueRef = useRef(value);
+ const normalizedOptions = getUniqueNonEmptyStrings(options ?? []);
+ const keyword = value.trim().toLowerCase();
+ const visibleOptions = normalizedOptions.filter((option) =>
+ filterOptions && hasTypedQuery && keyword
+ ? option.toLowerCase().includes(keyword)
+ : true,
+ );
+ const hasOptions = !readOnly && normalizedOptions.length > 0;
+ const floatingPosition = useFloatingPanelPosition(isOpen && hasOptions, wrapperRef);
+
+ useEffect(() => {
+ if (previousValueRef.current && !value) {
+ setIsOpen(false);
+ }
+ previousValueRef.current = value;
+ }, [value]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ function handleDocumentMouseDown(event: MouseEvent) {
+ const target = event.target as Node;
+ if (
+ wrapperRef.current?.contains(target) ||
+ panelRef.current?.contains(target)
+ ) {
+ return;
+ }
+ setIsOpen(false);
+ }
+
+ document.addEventListener("mousedown", handleDocumentMouseDown);
+ return () =>
+ document.removeEventListener("mousedown", handleDocumentMouseDown);
+ }, [isOpen]);
+
+ const closeLater = useCallback(() => {
+ setIsOpen(false);
+ }, []);
+
+ const handleSelect = useCallback(
+ (option: string) => {
+ onChange(option);
+ onSelect?.(option);
+ setIsOpen(false);
+ },
+ [onChange, onSelect],
+ );
+
+ return (
+
+
{
+ const nextValue = event.target.value;
+ setHasTypedQuery(true);
+ onChange(nextValue);
+ if (hasOptions) {
+ setIsOpen(closeOnChange?.(nextValue) ? false : true);
+ }
+ }}
+ onPaste={onPaste}
+ onFocus={() => {
+ setHasTypedQuery(false);
+ if (hasOptions) setIsOpen(true);
+ }}
+ onBlur={() => {
+ setHasTypedQuery(false);
+ closeLater();
+ onCommit?.();
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Escape") {
+ setHasTypedQuery(false);
+ setIsOpen(false);
+ return;
+ }
+ if (event.key === "Enter") {
+ event.preventDefault();
+ onCommit?.();
+ setHasTypedQuery(false);
+ setIsOpen(false);
+ }
+ }}
+ placeholder={placeholder}
+ readOnly={readOnly}
+ className={inputClassName}
+ />
+ {hasOptions && showChevron ? (
+
+ ) : null}
+ {hasOptions && isOpen && floatingPosition
+ ? createPortal(
+
+ {visibleOptions.length > 0 ? (
+ visibleOptions.map((option) => (
+
+ ))
+ ) : (
+
+ 没有匹配项
+
+ )}
+
,
+ document.body,
+ )
+ : null}
+
+ );
+}
+export type InlineSelectOption =
+ | string
+ | {
+ value: string;
+ label?: string;
+ disabled?: boolean;
+ };
+
+function normalizeInlineSelectOptions(
+ options: readonly InlineSelectOption[] | undefined,
+): { value: string; label: string; disabled?: boolean }[] {
+ return (options ?? []).map((option) =>
+ typeof option === "string"
+ ? { value: option, label: option }
+ : { value: option.value, label: option.label ?? option.value, disabled: option.disabled },
+ );
+}
+
+export function InlineSelect({
+ ariaLabel,
+ dataTestId,
+ value,
+ options,
+ placeholder,
+ readOnly = false,
+ wrapperClassName = "relative",
+ buttonClassName,
+ panelClassName = "absolute left-0 right-0 top-[calc(100%+4px)] z-50 max-h-48 overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md",
+ optionClassName = "flex w-full items-center rounded-apple-md px-2.5 py-1.5 text-left text-xs transition",
+ includeEmptyOption,
+ emptyOptionDisabled = false,
+ showIndicatorWhenReadOnly = false,
+ onChange,
+}: {
+ ariaLabel: string;
+ dataTestId?: string;
+ value: string;
+ options?: readonly InlineSelectOption[];
+ placeholder?: string;
+ readOnly?: boolean;
+ wrapperClassName?: string;
+ buttonClassName?: string;
+ panelClassName?: string;
+ optionClassName?: string;
+ includeEmptyOption?: boolean;
+ emptyOptionDisabled?: boolean;
+ showIndicatorWhenReadOnly?: boolean;
+ onChange: (value: string) => void;
+}) {
+ const listId = useId();
+ const containerRef = useRef(null);
+ const panelRef = useRef(null);
+ const [isOpen, setIsOpen] = useState(false);
+ const normalizedOptions = normalizeInlineSelectOptions(options);
+ const shouldIncludeEmptyOption = includeEmptyOption ?? false;
+ const selectOptions = shouldIncludeEmptyOption
+ ? [
+ {
+ value: "",
+ label: placeholder ?? "",
+ disabled: emptyOptionDisabled,
+ },
+ ...normalizedOptions,
+ ]
+ : normalizedOptions;
+ const selectedOption = selectOptions.find((option) => option.value === value);
+ const displayValue = selectedOption?.label ?? (value || placeholder || "");
+ const hasOptions = !readOnly && selectOptions.length > 0;
+ const shouldShowIndicator =
+ hasOptions || (readOnly && showIndicatorWhenReadOnly && selectOptions.length > 0);
+ const floatingPosition = useFloatingPanelPosition(isOpen && hasOptions, containerRef);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ function handleDocumentMouseDown(event: MouseEvent) {
+ const target = event.target as Node;
+ if (
+ containerRef.current?.contains(target) ||
+ panelRef.current?.contains(target)
+ ) {
+ return;
+ }
+ setIsOpen(false);
+ }
+
+ document.addEventListener("mousedown", handleDocumentMouseDown);
+ return () =>
+ document.removeEventListener("mousedown", handleDocumentMouseDown);
+ }, [isOpen]);
+
+ return (
+
+
+ {hasOptions && isOpen && floatingPosition
+ ? createPortal(
+
+ {selectOptions.map((option) => (
+
+ ))}
+
,
+ document.body,
+ )
+ : null}
+
+ );
+}
+
+export function SearchableInlineSelect({
+ ariaLabel,
+ dataTestId,
+ value,
+ options,
+ placeholder,
+ readOnly = false,
+ wrapperClassName = "relative",
+ inputClassName,
+ panelClassName = "absolute left-0 right-0 top-[calc(100%+4px)] z-50 max-h-48 overflow-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-1 shadow-apple-md",
+ optionClassName = "flex w-full items-center rounded-apple-md px-2.5 py-1.5 text-left text-xs transition",
+ includeEmptyOption,
+ emptyOptionDisabled = false,
+ onChange,
+}: {
+ ariaLabel: string;
+ dataTestId?: string;
+ value: string;
+ options?: readonly InlineSelectOption[];
+ placeholder?: string;
+ readOnly?: boolean;
+ wrapperClassName?: string;
+ inputClassName?: string;
+ panelClassName?: string;
+ optionClassName?: string;
+ includeEmptyOption?: boolean;
+ emptyOptionDisabled?: boolean;
+ onChange: (value: string) => void;
+}) {
+ const listId = useId();
+ const containerRef = useRef(null);
+ const panelRef = useRef(null);
+ const inputRef = useRef(null);
+ const [isOpen, setIsOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const normalizedOptions = normalizeInlineSelectOptions(options);
+ const shouldIncludeEmptyOption = includeEmptyOption ?? false;
+ const selectOptions = shouldIncludeEmptyOption
+ ? [
+ {
+ value: "",
+ label: placeholder ?? "",
+ disabled: emptyOptionDisabled,
+ },
+ ...normalizedOptions,
+ ]
+ : normalizedOptions;
+ const selectedOption = selectOptions.find((option) => option.value === value);
+ const displayValue = selectedOption?.label ?? value;
+ const normalizedQuery = query.trim().toLowerCase();
+ const normalizedDisplayValue = displayValue.trim().toLowerCase();
+ const shouldFilterOptions =
+ normalizedQuery.length > 0 && normalizedQuery !== normalizedDisplayValue;
+ const visibleOptions = selectOptions.filter((option) => {
+ if (!shouldFilterOptions) return true;
+ return (
+ option.label.toLowerCase().includes(normalizedQuery) ||
+ option.value.toLowerCase().includes(normalizedQuery)
+ );
+ });
+ const hasOptions = !readOnly && selectOptions.length > 0;
+ const floatingPosition = useFloatingPanelPosition(isOpen && hasOptions, containerRef);
+
+ useEffect(() => {
+ if (isOpen) return;
+ setQuery(displayValue);
+ }, [displayValue, isOpen]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ function handleDocumentMouseDown(event: MouseEvent) {
+ const target = event.target as Node;
+ if (
+ containerRef.current?.contains(target) ||
+ panelRef.current?.contains(target)
+ ) {
+ return;
+ }
+ setIsOpen(false);
+ }
+
+ document.addEventListener("mousedown", handleDocumentMouseDown);
+ return () =>
+ document.removeEventListener("mousedown", handleDocumentMouseDown);
+ }, [isOpen]);
+
+ const openPanel = useCallback(() => {
+ if (!hasOptions) return;
+ setQuery(displayValue);
+ setIsOpen(true);
+ window.setTimeout(() => inputRef.current?.select(), 0);
+ }, [displayValue, hasOptions]);
+
+ const handleSelect = useCallback(
+ (option: { value: string; label: string; disabled?: boolean }) => {
+ if (option.disabled) return;
+ onChange(option.value);
+ setQuery(option.label);
+ setIsOpen(false);
+ },
+ [onChange],
+ );
+
+ return (
+
+
{
+ if (!hasOptions) return;
+ setQuery(event.target.value);
+ setIsOpen(true);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Escape") {
+ setIsOpen(false);
+ return;
+ }
+ if (event.key === "Enter") {
+ event.preventDefault();
+ const firstEnabledOption = visibleOptions.find(
+ (option) => !option.disabled,
+ );
+ if (firstEnabledOption) {
+ handleSelect(firstEnabledOption);
+ } else {
+ setIsOpen(false);
+ }
+ }
+ }}
+ onBlur={() => {
+ setQuery(displayValue);
+ setIsOpen(false);
+ }}
+ className={inputClassName}
+ />
+ {hasOptions ? (
+
+ ) : null}
+ {hasOptions && isOpen && floatingPosition
+ ? createPortal(
+
+ {visibleOptions.length > 0 ? (
+ visibleOptions.map((option) => (
+
+ ))
+ ) : (
+
+ 没有匹配项
+
+ )}
+
,
+ document.body,
+ )
+ : null}
+
+ );
+}
diff --git a/furniture-3d-system/src/app/intake/non-integrated/components/WorkspaceShell.tsx b/furniture-3d-system/src/app/intake/non-integrated/components/WorkspaceShell.tsx
new file mode 100644
index 00000000..0b0de572
--- /dev/null
+++ b/furniture-3d-system/src/app/intake/non-integrated/components/WorkspaceShell.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+export function WorkspacePreviewEmptyState({
+ title,
+ description,
+ testId,
+}: {
+ title: string;
+ description: string;
+ testId?: string;
+}) {
+ return (
+
+
+ {title}
+
+
+ {description}
+
+
+ );
+}
+
+export function SectionCard({
+ title,
+ description,
+ children,
+ className = "",
+ bodyClassName = "",
+ headerActions,
+ compact = false,
+}: {
+ title?: string;
+ description?: string;
+ children: ReactNode;
+ className?: string;
+ bodyClassName?: string;
+ headerActions?: ReactNode;
+ compact?: boolean;
+}) {
+ const hasHeaderText = Boolean(title || description);
+ const hasHeader = hasHeaderText || Boolean(headerActions);
+
+ return (
+
+ {hasHeader ? (
+
+ {hasHeaderText ? (
+
+ {title ? (
+
+ {title}
+
+ ) : null}
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ ) : null}
+ {headerActions ? (
+
+ {headerActions}
+
+ ) : null}
+
+ ) : null}
+
{children}
+
+ );
+}