shadimage

Command

Command palette primitives for Tile UI.

packages/react/src/components/command/command.tsx

import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { commandStyleKeys, matchCommandItem } from '../lib/core';
import type { CommandBaseProps, CommandFilterFn } from '../lib/core';
import { usePortalContainer, type PortalContainer } from '../portal';
import styles from './command.module.scss';

interface CommandContextValue {
	search: string;
	setSearch: (value: string) => void;
	filter?: CommandFilterFn;
	loop: boolean;
	itemsRef: React.RefObject<HTMLElement[]>;
	visibleCount: number;
	reportVisibility: (visible: boolean) => void;
}

const CommandContext = createContext<CommandContextValue | null>(null);

function useCommandContext(): CommandContextValue {
	const context = useContext(CommandContext);
	if (!context) {
		throw new Error('Command 子组件必须位于 <Command> 内部。');
	}
	return context;
}

function CommandSearchIcon() {
	return (
		<svg
			className={styles[commandStyleKeys.inputIcon]}
			xmlns="http://www.w3.org/2000/svg"
			width="16"
			height="16"
			viewBox="0 0 24 24"
			fill="none"
			stroke="currentColor"
			strokeWidth="2"
			strokeLinecap="round"
			strokeLinejoin="round"
			aria-hidden="true">
			<circle cx="11" cy="11" r="8" />
			<path d="m21 21-4.3-4.3" />
		</svg>
	);
}

export interface CommandProps extends React.HTMLAttributes<HTMLDivElement>, CommandBaseProps {}

const Command = React.forwardRef<HTMLDivElement, CommandProps>(({ className = '', items, groups, filter, loop = true, children, ...props }, ref) => {
	const [search, setSearch] = useState('');
	const itemsRef = useRef<HTMLElement[]>([]);
	const [visibleCount, setVisibleCount] = useState(0);

	const reportVisibility = useCallback((visible: boolean) => {
		setVisibleCount((count) => count + (visible ? 1 : -1));
	}, []);

	const value = { search, setSearch, filter, loop, itemsRef, visibleCount, reportVisibility };

	const renderDataItems = (defs: { value: string; label?: string; keywords?: string[]; disabled?: boolean; shortcut?: string }[]) =>
		defs.map((item) => (
			<CommandItem key={item.value} value={item.value} keywords={item.keywords} disabled={item.disabled}>
				{item.label ?? item.value}
				{item.shortcut ? <CommandShortcut>{item.shortcut}</CommandShortcut> : null}
			</CommandItem>
		));

	let content = children;
	if (groups && groups.length > 0) {
		content = groups.map((group) => (
			<CommandGroup key={group.value} heading={group.label}>
				{renderDataItems(group.items)}
			</CommandGroup>
		));
	} else if (items && items.length > 0) {
		content = renderDataItems(items);
	}

	return (
		<CommandContext.Provider value={value}>
			<div ref={ref} className={`${styles[commandStyleKeys.root]} ${className}`} {...props}>
				{content}
			</div>
		</CommandContext.Provider>
	);
});
Command.displayName = 'Command';

export interface CommandInputProps extends React.InputHTMLAttributes<HTMLInputElement> {}

const CommandInput = React.forwardRef<HTMLInputElement, CommandInputProps>(({ className = '', value, onChange, onKeyDown, ...props }, ref) => {
	const context = useCommandContext();
	const { itemsRef, loop } = context;

	function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
		onChange?.(event);
		context.setSearch(event.target.value);
	}

	function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
		onKeyDown?.(event);
		if (event.defaultPrevented) {
			return;
		}

		const items = itemsRef.current.filter((item) => !item.hasAttribute('hidden') && item.getAttribute('data-disabled') !== 'true');
		if (items.length === 0) {
			return;
		}

		const currentIndex = items.findIndex((item) => item.getAttribute('data-selected') === 'true');

		const highlight = (next: number) => {
			items.forEach((item) => item.removeAttribute('data-selected'));
			items[next].setAttribute('data-selected', 'true');
			items[next].scrollIntoView({ block: 'nearest' });
		};

		const nextFrom = (direction: 1 | -1): number => {
			if (currentIndex < 0) {
				return direction === 1 ? 0 : items.length - 1;
			}
			const next = currentIndex + direction;
			if (next < 0) {
				return loop ? items.length - 1 : 0;
			}
			if (next >= items.length) {
				return loop ? 0 : items.length - 1;
			}
			return next;
		};

		switch (event.key) {
			case 'ArrowDown':
				event.preventDefault();
				highlight(nextFrom(1));
				break;
			case 'ArrowUp':
				event.preventDefault();
				highlight(nextFrom(-1));
				break;
			case 'Enter':
				event.preventDefault();
				(currentIndex >= 0 ? items[currentIndex] : items[0]).click();
				break;
		}
	}

	return (
		<div className={styles[commandStyleKeys.inputWrapper]}>
			<CommandSearchIcon />
			<input
				ref={ref}
				value={value ?? context.search}
				onChange={handleChange}
				onKeyDown={handleKeyDown}
				className={`${styles[commandStyleKeys.input]} ${className}`}
				{...props}
			/>
		</div>
	);
});
CommandInput.displayName = 'CommandInput';

export interface CommandListProps extends React.HTMLAttributes<HTMLDivElement> {}

const CommandList = React.forwardRef<HTMLDivElement, CommandListProps>(({ className = '', children, ...props }, ref) => {
	const { itemsRef, loop } = useCommandContext();

	function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
		const items = itemsRef.current.filter((item) => !item.hasAttribute('hidden') && item.getAttribute('data-disabled') !== 'true');
		if (items.length === 0) {
			return;
		}

		const currentIndex = items.findIndex((item) => item.getAttribute('data-selected') === 'true');

		const highlight = (next: number) => {
			items.forEach((item) => item.removeAttribute('data-selected'));
			items[next].setAttribute('data-selected', 'true');
			items[next].scrollIntoView({ block: 'nearest' });
		};

		const nextFrom = (direction: 1 | -1): number => {
			if (currentIndex < 0) {
				return direction === 1 ? 0 : items.length - 1;
			}
			const next = currentIndex + direction;
			if (next < 0) {
				return loop ? items.length - 1 : 0;
			}
			if (next >= items.length) {
				return loop ? 0 : items.length - 1;
			}
			return next;
		};

		switch (event.key) {
			case 'ArrowDown':
				event.preventDefault();
				highlight(nextFrom(1));
				break;
			case 'ArrowUp':
				event.preventDefault();
				highlight(nextFrom(-1));
				break;
			case 'Enter':
				event.preventDefault();
				(currentIndex >= 0 ? items[currentIndex] : items[0]).click();
				break;
		}
	}

	return (
		<div ref={ref} className={`${styles[commandStyleKeys.list]} ${className}`} onKeyDown={handleKeyDown} {...props}>
			{children}
		</div>
	);
});
CommandList.displayName = 'CommandList';

export interface CommandEmptyProps extends React.HTMLAttributes<HTMLDivElement> {}

const CommandEmpty = React.forwardRef<HTMLDivElement, CommandEmptyProps>(({ className = '', children, ...props }, ref) => {
	const { visibleCount } = useCommandContext();
	if (visibleCount > 0) {
		return null;
	}
	const classes = [styles[commandStyleKeys.empty], className].filter(Boolean).join(' ');
	return (
		<div ref={ref} className={classes} {...props}>
			{children}
		</div>
	);
});
CommandEmpty.displayName = 'CommandEmpty';

export interface CommandGroupProps extends React.HTMLAttributes<HTMLDivElement> {
	heading?: string;
}

const CommandGroup = React.forwardRef<HTMLDivElement, CommandGroupProps>(({ className = '', heading, children, ...props }, ref) => {
	return (
		<div ref={ref} className={`${styles[commandStyleKeys.group]} ${className}`} {...props}>
			{heading ? <div className={styles[commandStyleKeys.groupLabel]}>{heading}</div> : null}
			<div className={styles[commandStyleKeys.groupContent]}>{children}</div>
		</div>
	);
});
CommandGroup.displayName = 'CommandGroup';

export interface CommandItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'value' | 'onSelect'> {
	value: string;
	keywords?: string[];
	disabled?: boolean;
	onSelect?: (value: string) => void;
}

const CommandItem = React.forwardRef<HTMLDivElement, CommandItemProps>(({ className = '', value, keywords, disabled = false, onSelect, children, ...props }, ref) => {
	const context = useCommandContext();
	const itemRef = useRef<HTMLDivElement | null>(null);
	const { filter: filterFn, search, itemsRef, reportVisibility } = context;

	const matches = useMemo(() => {
		if (filterFn) {
			return filterFn(value, search, keywords);
		}
		return matchCommandItem({ value, keywords }, search);
	}, [value, keywords, search, filterFn]);

	useEffect(() => {
		const element = itemRef.current;
		if (!element) {
			return;
		}
		itemsRef.current.push(element);
		return () => {
			itemsRef.current = itemsRef.current.filter((item) => item !== element);
		};
	}, [itemsRef]);

	useEffect(() => {
		if (matches) {
			reportVisibility(true);
			return () => reportVisibility(false);
		}
	}, [matches, reportVisibility]);

	function setRef(element: HTMLDivElement | null) {
		itemRef.current = element;
		if (typeof ref === 'function') {
			ref(element);
		} else if (ref) {
			ref.current = element;
		}
	}

	function handleClick() {
		if (disabled) {
			return;
		}
		onSelect?.(value);
	}

	const classes = [styles[commandStyleKeys.item], className].filter(Boolean).join(' ');
	return (
		<div ref={setRef} tabIndex={-1} hidden={!matches} data-disabled={disabled} className={classes} onClick={handleClick} {...props}>
			{children}
		</div>
	);
});
CommandItem.displayName = 'CommandItem';

export interface CommandSeparatorProps extends React.HTMLAttributes<HTMLDivElement> {}

const CommandSeparator = React.forwardRef<HTMLDivElement, CommandSeparatorProps>(({ className = '', ...props }, ref) => {
	const classes = [styles[commandStyleKeys.separator], className].filter(Boolean).join(' ');
	return <div ref={ref} className={classes} {...props} />;
});
CommandSeparator.displayName = 'CommandSeparator';

export interface CommandShortcutProps extends React.HTMLAttributes<HTMLSpanElement> {}

const CommandShortcut = React.forwardRef<HTMLSpanElement, CommandShortcutProps>(({ className = '', children, ...props }, ref) => {
	const classes = [styles[commandStyleKeys.shortcut], className].filter(Boolean).join(' ');
	return (
		<span ref={ref} className={classes} {...props}>
			{children}
		</span>
	);
});
CommandShortcut.displayName = 'CommandShortcut';

export interface CommandDialogProps {
	open?: boolean;
	onOpenChange?: (open: boolean) => void;
	title?: string;
	description?: string;
	className?: string;
	showCloseButton?: boolean;
	container?: PortalContainer;
	children?: React.ReactNode;
}

const CommandDialog = ({
	open = false,
	onOpenChange,
	title = 'Command Palette',
	description = 'Search for a command to run...',
	className = '',
	showCloseButton = true,
	container,
	children,
}: CommandDialogProps) => {
	const portalContainer = usePortalContainer(container);
	if (!open) {
		return null;
	}

	function handleOverlayClick() {
		onOpenChange?.(false);
	}

	function handleCloseClick() {
		onOpenChange?.(false);
	}

	function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
		if (event.key === 'Escape') {
			onOpenChange?.(false);
		}
	}

	const content = (
		<div onKeyDown={handleKeyDown}>
			<div className={styles[commandStyleKeys.dialogOverlay]} onClick={handleOverlayClick} />
			<div className={`${styles[commandStyleKeys.dialogContent]} ${className}`} role="dialog" aria-modal="true">
				<h2 className={styles[commandStyleKeys.dialogTitle]}>{title}</h2>
				<p className={styles[commandStyleKeys.dialogDescription]}>{description}</p>
				{children}
				{showCloseButton && (
					<button type="button" aria-label="关闭" className={styles[commandStyleKeys.dialogClose]} onClick={handleCloseClick}>
						<svg
							xmlns="http://www.w3.org/2000/svg"
							width="16"
							height="16"
							viewBox="0 0 24 24"
							fill="none"
							stroke="currentColor"
							strokeWidth="2"
							strokeLinecap="round"
							strokeLinejoin="round"
							aria-hidden="true">
							<path d="M18 6 6 18" />
							<path d="m6 6 12 12" />
						</svg>
					</button>
				)}
			</div>
		</div>
	);

	if (!portalContainer) {
		return null;
	}

	return createPortal(content, portalContainer);
};
CommandDialog.displayName = 'CommandDialog';

export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator, CommandShortcut, CommandDialog };
export default Command;

No selection tags yet — empty means no evidence, not lack of support.

DependenciesBundle weight · light

react-dom