refactor(intake): extract non-integrated shared controls
This commit is contained in:
parent
d7b47c21f5
commit
ff2d99ae70
@ -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();
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user