refactor(intake): extract non-integrated shared controls

This commit is contained in:
WuYongJie 2026-06-08 13:33:56 +08:00
parent d7b47c21f5
commit ff2d99ae70
4 changed files with 937 additions and 890 deletions

View File

@ -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 (
<label className="mb-1.5 block text-xs text-apple-label-secondary">
{required ? <span className="mr-1 text-[#d94b45]">*</span> : null}
{label}
</label>
);
}
function CreateFieldShell({ children }: { children: ReactNode }) {
return (
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-2">
{children}
</div>
);
}
type FloatingPanelPosition = {
left: number;
top: number;
width: number;
maxWidth: number;
maxHeight: number;
};
function useFloatingPanelPosition(
isOpen: boolean,
anchorRef: RefObject<HTMLElement | null>,
gap = 4,
) {
const [position, setPosition] = useState<FloatingPanelPosition | null>(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<HTMLInputElement>) => void;
onChange: (value: string) => void;
onSelect?: (value: string) => void;
onCommit?: () => void;
}) {
const suggestionListId = useId();
const wrapperRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(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 (
<div ref={wrapperRef} className={wrapperClassName}>
<input
type="text"
aria-label={ariaLabel}
aria-controls={hasOptions ? suggestionListId : undefined}
aria-expanded={hasOptions ? isOpen : undefined}
role={hasOptions ? "combobox" : undefined}
data-testid={dataTestId}
value={value}
autoComplete="off"
onChange={(event) => {
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 ? (
<button
type="button"
tabIndex={-1}
aria-label={`展开${ariaLabel}选项`}
onMouseDown={(event) => {
event.preventDefault();
setIsOpen((current) => !current);
}}
className="absolute right-2 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-apple-md text-apple-label-secondary transition hover:bg-apple-bg-tertiary hover:text-apple-label-primary"
>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</button>
) : null}
{hasOptions && isOpen && floatingPosition
? createPortal(
<div
ref={panelRef}
id={suggestionListId}
role="listbox"
className={panelClassName}
style={{
position: "fixed",
left: floatingPosition.left,
right: "auto",
top: floatingPosition.top,
minWidth: floatingPosition.width,
maxWidth: floatingPosition.maxWidth,
maxHeight: floatingPosition.maxHeight,
zIndex: 9999,
}}
>
{visibleOptions.length > 0 ? (
visibleOptions.map((option) => (
<button
key={option}
type="button"
role="option"
aria-selected={option === value}
onMouseDown={(event) => {
event.preventDefault();
handleSelect(option);
}}
className={`${optionClassName} ${
option === value
? "bg-apple-bg-secondary text-apple-label-primary"
: "text-apple-label-primary hover:bg-apple-bg-secondary"
}`}
>
<span className="min-w-0 truncate">{option}</span>
</button>
))
) : (
<div className="px-2.5 py-2 text-xs text-apple-label-secondary">
没有匹配项
</div>
)}
</div>,
document.body,
)
: null}
</div>
);
}
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<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className={wrapperClassName}>
<button
type="button"
aria-label={ariaLabel}
aria-controls={hasOptions ? listId : undefined}
aria-expanded={hasOptions ? isOpen : undefined}
aria-haspopup={hasOptions ? "listbox" : undefined}
role="combobox"
data-testid={dataTestId}
value={value}
disabled={readOnly}
onClick={() => {
if (hasOptions) setIsOpen((current) => !current);
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
setIsOpen(false);
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (hasOptions) setIsOpen((current) => !current);
}
}}
className={`flex items-center justify-between gap-2 text-left ${buttonClassName ?? ""}`}
>
<span className="min-w-0 flex-1 truncate">
{displayValue || placeholder}
</span>
{shouldShowIndicator ? (
<ChevronDown
className="h-4 w-4 shrink-0 text-apple-label-secondary"
aria-hidden="true"
/>
) : null}
</button>
{hasOptions && isOpen && floatingPosition
? createPortal(
<div
ref={panelRef}
id={listId}
role="listbox"
className={panelClassName}
style={{
position: "fixed",
left: floatingPosition.left,
right: "auto",
top: floatingPosition.top,
minWidth: floatingPosition.width,
maxWidth: floatingPosition.maxWidth,
maxHeight: floatingPosition.maxHeight,
zIndex: 9999,
}}
>
{selectOptions.map((option) => (
<button
key={`${option.value}-${option.label}`}
type="button"
role="option"
aria-selected={option.value === value}
value={option.value}
disabled={option.disabled}
onMouseDown={(event) => {
event.preventDefault();
if (option.disabled) return;
onChange(option.value);
setIsOpen(false);
}}
className={`${optionClassName} ${
option.value === value
? "bg-apple-bg-secondary text-apple-label-primary"
: "text-apple-label-primary hover:bg-apple-bg-secondary"
} disabled:cursor-not-allowed disabled:text-apple-label-tertiary disabled:hover:bg-transparent`}
>
<span className="min-w-0 truncate">{option.label}</span>
</button>
))}
</div>,
document.body,
)
: null}
</div>
);
}
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<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div ref={containerRef} className={wrapperClassName}>
<input
ref={inputRef}
type="text"
aria-label={ariaLabel}
aria-controls={hasOptions ? listId : undefined}
aria-expanded={hasOptions ? isOpen : undefined}
aria-haspopup={hasOptions ? "listbox" : undefined}
role={hasOptions ? "combobox" : undefined}
data-testid={dataTestId}
value={isOpen ? query : displayValue}
autoComplete="off"
readOnly={readOnly || !hasOptions}
placeholder={placeholder}
onFocus={openPanel}
onChange={(event) => {
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 ? (
<button
type="button"
tabIndex={-1}
aria-label={`展开${ariaLabel}选项`}
onMouseDown={(event) => {
event.preventDefault();
if (isOpen) {
setIsOpen(false);
return;
}
openPanel();
}}
className="absolute right-2 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-apple-md text-apple-label-secondary transition hover:bg-apple-bg-tertiary hover:text-apple-label-primary"
>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</button>
) : null}
{hasOptions && isOpen && floatingPosition
? createPortal(
<div
ref={panelRef}
id={listId}
role="listbox"
className={panelClassName}
style={{
position: "fixed",
left: floatingPosition.left,
right: "auto",
top: floatingPosition.top,
minWidth: floatingPosition.width,
maxWidth: floatingPosition.maxWidth,
maxHeight: floatingPosition.maxHeight,
zIndex: 9999,
}}
>
{visibleOptions.length > 0 ? (
visibleOptions.map((option) => (
<button
key={`${option.value}-${option.label}`}
type="button"
role="option"
aria-selected={option.value === value}
value={option.value}
disabled={option.disabled}
onMouseDown={(event) => {
event.preventDefault();
handleSelect(option);
}}
className={`${optionClassName} ${
option.value === value
? "bg-apple-bg-secondary text-apple-label-primary"
: "text-apple-label-primary hover:bg-apple-bg-secondary"
} disabled:cursor-not-allowed disabled:text-apple-label-tertiary disabled:hover:bg-transparent`}
>
<span className="min-w-0 truncate">{option.label}</span>
</button>
))
) : (
<div className="px-2.5 py-2 text-xs text-apple-label-secondary">
没有匹配项
</div>
)}
</div>,
document.body,
)
: null}
</div>
);
}
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 (
<div
className={
compact
? ""
: "rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-2"
}
>
<FieldLabel label={label} required={required} />
{searchableSelect ? (
<SearchableInlineSelect
ariaLabel={label}
value={value}
options={options}
placeholder={placeholder}
readOnly={readOnly}
onChange={onChange}
inputClassName={`h-9 w-full rounded-apple-lg border border-apple-separator px-3 pr-9 text-sm text-apple-label-primary outline-none disabled:cursor-not-allowed ${
readOnly
? "bg-apple-bg-primary text-apple-label-secondary"
: "bg-apple-bg-secondary"
}`}
/>
) : (
<InlineSelect
ariaLabel={label}
value={value}
options={options}
placeholder={placeholder}
readOnly={readOnly}
onChange={onChange}
buttonClassName={`h-9 w-full rounded-apple-lg border border-apple-separator px-3 text-sm text-apple-label-primary outline-none disabled:cursor-not-allowed ${
readOnly
? "bg-apple-bg-primary text-apple-label-secondary"
: "bg-apple-bg-secondary"
}`}
/>
)}
</div>
);
}
if (type === "textarea") {
return (
<div
className={
compact
? ""
: "rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-2"
}
>
<FieldLabel label={label} required={required} />
<textarea
rows={compact ? 2 : 3}
aria-label={label}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
readOnly={readOnly}
className={`w-full rounded-apple-lg border border-apple-separator px-3 py-2 text-sm text-apple-label-primary outline-none ${
compact ? "min-h-[64px]" : "min-h-[88px]"
} ${
readOnly
? "bg-apple-bg-primary text-apple-label-secondary"
: "bg-apple-bg-secondary"
}`}
/>
</div>
);
}
return (
<div
className={
compact
? ""
: "rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-2"
}
>
<FieldLabel label={label} required={required} />
<InlineSuggestionInput
ariaLabel={label}
value={value}
options={options}
placeholder={placeholder}
readOnly={readOnly}
filterOptions={Boolean((options ?? []).length)}
onChange={onChange}
inputClassName={`h-9 w-full rounded-apple-lg border border-apple-separator px-3 text-sm text-apple-label-primary outline-none ${
!readOnly && (options ?? []).length > 0 ? "pr-9" : ""
} ${
readOnly
? "bg-apple-bg-primary text-apple-label-secondary"
: "bg-apple-bg-secondary"
}`}
/>
</div>
);
}
function ImagePreviewDialog({
src,
alt,
@ -9602,30 +8813,6 @@ function SeriesComponentDetailsDialog({
);
}
function WorkspacePreviewEmptyState({
title,
description,
testId,
}: {
title: string;
description: string;
testId?: string;
}) {
return (
<div
data-testid={testId}
className="flex h-full w-full flex-col items-center justify-center rounded-apple-xl border border-dashed border-apple-separator bg-apple-bg-primary px-5 text-center"
>
<p className="text-sm font-apple-medium text-apple-label-primary">
{title}
</p>
<p className="mt-2 text-xs leading-5 text-apple-label-secondary">
{description}
</p>
</div>
);
}
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 (
<div
className={`rounded-apple-lg border border-apple-separator bg-apple-bg-primary ${
compact ? "p-2" : "p-3"
} ${
className || ""
}`}
>
{hasHeader ? (
<div
className={`flex items-start justify-between ${
compact ? "mb-2 gap-2" : "mb-3 gap-3"
}`}
>
{hasHeaderText ? (
<div className="min-w-0 flex-1">
{title ? (
<h3 className="text-sm font-apple-semibold text-apple-label-primary">
{title}
</h3>
) : null}
{description ? (
<p
className={`text-apple-label-secondary ${
compact ? "mt-0.5 text-[11px]" : "mt-1 text-xs"
}`}
>
{description}
</p>
) : null}
</div>
) : null}
{headerActions ? (
<div className={hasHeaderText ? "shrink-0" : "ml-auto shrink-0"}>
{headerActions}
</div>
) : null}
</div>
) : null}
<div className={bodyClassName}>{children}</div>
</div>
);
}
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();

View File

@ -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 (
<label className="mb-1.5 block text-xs text-apple-label-secondary">
{required ? <span className="mr-1 text-[#d94b45]">*</span> : null}
{label}
</label>
);
}
export function CreateFieldShell({ children }: { children: ReactNode }) {
return (
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-2">
{children}
</div>
);
}
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 (
<div
className={
compact
? ""
: "rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-2"
}
>
<FieldLabel label={label} required={required} />
{searchableSelect ? (
<SearchableInlineSelect
ariaLabel={label}
value={value}
options={options}
placeholder={placeholder}
readOnly={readOnly}
onChange={onChange}
inputClassName={`h-9 w-full rounded-apple-lg border border-apple-separator px-3 pr-9 text-sm text-apple-label-primary outline-none disabled:cursor-not-allowed ${
readOnly
? "bg-apple-bg-primary text-apple-label-secondary"
: "bg-apple-bg-secondary"
}`}
/>
) : (
<InlineSelect
ariaLabel={label}
value={value}
options={options}
placeholder={placeholder}
readOnly={readOnly}
onChange={onChange}
buttonClassName={`h-9 w-full rounded-apple-lg border border-apple-separator px-3 text-sm text-apple-label-primary outline-none disabled:cursor-not-allowed ${
readOnly
? "bg-apple-bg-primary text-apple-label-secondary"
: "bg-apple-bg-secondary"
}`}
/>
)}
</div>
);
}
if (type === "textarea") {
return (
<div
className={
compact
? ""
: "rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-2"
}
>
<FieldLabel label={label} required={required} />
<textarea
rows={compact ? 2 : 3}
aria-label={label}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
readOnly={readOnly}
className={`w-full rounded-apple-lg border border-apple-separator px-3 py-2 text-sm text-apple-label-primary outline-none ${
compact ? "min-h-[64px]" : "min-h-[88px]"
} ${
readOnly
? "bg-apple-bg-primary text-apple-label-secondary"
: "bg-apple-bg-secondary"
}`}
/>
</div>
);
}
return (
<div
className={
compact
? ""
: "rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-2"
}
>
<FieldLabel label={label} required={required} />
<InlineSuggestionInput
ariaLabel={label}
value={value}
options={options}
placeholder={placeholder}
readOnly={readOnly}
filterOptions={Boolean((options ?? []).length)}
onChange={onChange}
inputClassName={`h-9 w-full rounded-apple-lg border border-apple-separator px-3 text-sm text-apple-label-primary outline-none ${
!readOnly && (options ?? []).length > 0 ? "pr-9" : ""
} ${
readOnly
? "bg-apple-bg-primary text-apple-label-secondary"
: "bg-apple-bg-secondary"
}`}
/>
</div>
);
}

View File

@ -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<string | null | undefined>,
) {
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<HTMLElement | null>,
gap = 4,
) {
const [position, setPosition] = useState<FloatingPanelPosition | null>(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<HTMLInputElement>) => void;
onChange: (value: string) => void;
onSelect?: (value: string) => void;
onCommit?: () => void;
}) {
const suggestionListId = useId();
const wrapperRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(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 (
<div ref={wrapperRef} className={wrapperClassName}>
<input
type="text"
aria-label={ariaLabel}
aria-controls={hasOptions ? suggestionListId : undefined}
aria-expanded={hasOptions ? isOpen : undefined}
role={hasOptions ? "combobox" : undefined}
data-testid={dataTestId}
value={value}
autoComplete="off"
onChange={(event) => {
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 ? (
<button
type="button"
tabIndex={-1}
aria-label={`展开${ariaLabel}选项`}
onMouseDown={(event) => {
event.preventDefault();
setIsOpen((current) => !current);
}}
className="absolute right-2 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-apple-md text-apple-label-secondary transition hover:bg-apple-bg-tertiary hover:text-apple-label-primary"
>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</button>
) : null}
{hasOptions && isOpen && floatingPosition
? createPortal(
<div
ref={panelRef}
id={suggestionListId}
role="listbox"
className={panelClassName}
style={{
position: "fixed",
left: floatingPosition.left,
right: "auto",
top: floatingPosition.top,
minWidth: floatingPosition.width,
maxWidth: floatingPosition.maxWidth,
maxHeight: floatingPosition.maxHeight,
zIndex: 9999,
}}
>
{visibleOptions.length > 0 ? (
visibleOptions.map((option) => (
<button
key={option}
type="button"
role="option"
aria-selected={option === value}
onMouseDown={(event) => {
event.preventDefault();
handleSelect(option);
}}
className={`${optionClassName} ${
option === value
? "bg-apple-bg-secondary text-apple-label-primary"
: "text-apple-label-primary hover:bg-apple-bg-secondary"
}`}
>
<span className="min-w-0 truncate">{option}</span>
</button>
))
) : (
<div className="px-2.5 py-2 text-xs text-apple-label-secondary">
没有匹配项
</div>
)}
</div>,
document.body,
)
: null}
</div>
);
}
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<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className={wrapperClassName}>
<button
type="button"
aria-label={ariaLabel}
aria-controls={hasOptions ? listId : undefined}
aria-expanded={hasOptions ? isOpen : undefined}
aria-haspopup={hasOptions ? "listbox" : undefined}
role="combobox"
data-testid={dataTestId}
value={value}
disabled={readOnly}
onClick={() => {
if (hasOptions) setIsOpen((current) => !current);
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
setIsOpen(false);
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (hasOptions) setIsOpen((current) => !current);
}
}}
className={`flex items-center justify-between gap-2 text-left ${buttonClassName ?? ""}`}
>
<span className="min-w-0 flex-1 truncate">
{displayValue || placeholder}
</span>
{shouldShowIndicator ? (
<ChevronDown
className="h-4 w-4 shrink-0 text-apple-label-secondary"
aria-hidden="true"
/>
) : null}
</button>
{hasOptions && isOpen && floatingPosition
? createPortal(
<div
ref={panelRef}
id={listId}
role="listbox"
className={panelClassName}
style={{
position: "fixed",
left: floatingPosition.left,
right: "auto",
top: floatingPosition.top,
minWidth: floatingPosition.width,
maxWidth: floatingPosition.maxWidth,
maxHeight: floatingPosition.maxHeight,
zIndex: 9999,
}}
>
{selectOptions.map((option) => (
<button
key={`${option.value}-${option.label}`}
type="button"
role="option"
aria-selected={option.value === value}
value={option.value}
disabled={option.disabled}
onMouseDown={(event) => {
event.preventDefault();
if (option.disabled) return;
onChange(option.value);
setIsOpen(false);
}}
className={`${optionClassName} ${
option.value === value
? "bg-apple-bg-secondary text-apple-label-primary"
: "text-apple-label-primary hover:bg-apple-bg-secondary"
} disabled:cursor-not-allowed disabled:text-apple-label-tertiary disabled:hover:bg-transparent`}
>
<span className="min-w-0 truncate">{option.label}</span>
</button>
))}
</div>,
document.body,
)
: null}
</div>
);
}
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<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div ref={containerRef} className={wrapperClassName}>
<input
ref={inputRef}
type="text"
aria-label={ariaLabel}
aria-controls={hasOptions ? listId : undefined}
aria-expanded={hasOptions ? isOpen : undefined}
aria-haspopup={hasOptions ? "listbox" : undefined}
role={hasOptions ? "combobox" : undefined}
data-testid={dataTestId}
value={isOpen ? query : displayValue}
autoComplete="off"
readOnly={readOnly || !hasOptions}
placeholder={placeholder}
onFocus={openPanel}
onChange={(event) => {
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 ? (
<button
type="button"
tabIndex={-1}
aria-label={`展开${ariaLabel}选项`}
onMouseDown={(event) => {
event.preventDefault();
if (isOpen) {
setIsOpen(false);
return;
}
openPanel();
}}
className="absolute right-2 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-apple-md text-apple-label-secondary transition hover:bg-apple-bg-tertiary hover:text-apple-label-primary"
>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</button>
) : null}
{hasOptions && isOpen && floatingPosition
? createPortal(
<div
ref={panelRef}
id={listId}
role="listbox"
className={panelClassName}
style={{
position: "fixed",
left: floatingPosition.left,
right: "auto",
top: floatingPosition.top,
minWidth: floatingPosition.width,
maxWidth: floatingPosition.maxWidth,
maxHeight: floatingPosition.maxHeight,
zIndex: 9999,
}}
>
{visibleOptions.length > 0 ? (
visibleOptions.map((option) => (
<button
key={`${option.value}-${option.label}`}
type="button"
role="option"
aria-selected={option.value === value}
value={option.value}
disabled={option.disabled}
onMouseDown={(event) => {
event.preventDefault();
handleSelect(option);
}}
className={`${optionClassName} ${
option.value === value
? "bg-apple-bg-secondary text-apple-label-primary"
: "text-apple-label-primary hover:bg-apple-bg-secondary"
} disabled:cursor-not-allowed disabled:text-apple-label-tertiary disabled:hover:bg-transparent`}
>
<span className="min-w-0 truncate">{option.label}</span>
</button>
))
) : (
<div className="px-2.5 py-2 text-xs text-apple-label-secondary">
没有匹配项
</div>
)}
</div>,
document.body,
)
: null}
</div>
);
}

View File

@ -0,0 +1,89 @@
"use client";
import type { ReactNode } from "react";
export function WorkspacePreviewEmptyState({
title,
description,
testId,
}: {
title: string;
description: string;
testId?: string;
}) {
return (
<div
data-testid={testId}
className="flex h-full w-full flex-col items-center justify-center rounded-apple-xl border border-dashed border-apple-separator bg-apple-bg-primary px-5 text-center"
>
<p className="text-sm font-apple-medium text-apple-label-primary">
{title}
</p>
<p className="mt-2 text-xs leading-5 text-apple-label-secondary">
{description}
</p>
</div>
);
}
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 (
<div
className={`rounded-apple-lg border border-apple-separator bg-apple-bg-primary ${
compact ? "p-2" : "p-3"
} ${className || ""}`}
>
{hasHeader ? (
<div
className={`flex items-start justify-between ${
compact ? "mb-2 gap-2" : "mb-3 gap-3"
}`}
>
{hasHeaderText ? (
<div className="min-w-0 flex-1">
{title ? (
<h3 className="text-sm font-apple-semibold text-apple-label-primary">
{title}
</h3>
) : null}
{description ? (
<p
className={`text-apple-label-secondary ${
compact ? "mt-0.5 text-[11px]" : "mt-1 text-xs"
}`}
>
{description}
</p>
) : null}
</div>
) : null}
{headerActions ? (
<div className={hasHeaderText ? "shrink-0" : "ml-auto shrink-0"}>
{headerActions}
</div>
) : null}
</div>
) : null}
<div className={bodyClassName}>{children}</div>
</div>
);
}