haloforge-plugin-api 0.2.16

Plugin API for HaloForge — traits and types for building native HaloForge plugins
Documentation
import {
  Children,
  isValidElement,
  useEffect,
  useMemo,
  useRef,
  useState,
  type ReactElement,
  type ReactNode,
} from "react";
import { Check, ChevronDown } from "lucide-react";

export interface AppSelectChangeEvent {
  target: {
    value: string;
  };
}

export interface AppSelectProps {
  children: ReactNode;
  className?: string;
  disabled?: boolean;
  id?: string;
  onChange?: (event: AppSelectChangeEvent) => void;
  menuClassName?: string;
  optionClassName?: string;
  placement?: "bottom" | "top";
  title?: string;
  value?: string | null;
  "aria-label"?: string;
  "aria-labelledby"?: string;
}

interface ParsedOption {
  key: string;
  value: string;
  label: ReactNode;
  disabled: boolean;
}

type OptionElement = ReactElement<{
  value?: string;
  children?: ReactNode;
  disabled?: boolean;
}, "option">;

function cx(...parts: Array<string | false | null | undefined>) {
  return parts.filter(Boolean).join(" ");
}

function parseOptions(children: ReactNode): ParsedOption[] {
  const options: ParsedOption[] = [];

  Children.forEach(children, (child, index) => {
    if (!isValidElement(child) || child.type !== "option") {
      return;
    }

    const option = child as OptionElement;
    options.push({
      key: option.key?.toString() ?? `option-${index}-${String(option.props.value ?? "")}`,
      value: String(option.props.value ?? ""),
      label: option.props.children,
      disabled: Boolean(option.props.disabled),
    });
  });

  return options;
}

export function AppSelect({
  children,
  className,
  disabled = false,
  onChange,
  optionClassName,
  menuClassName,
  placement = "bottom",
  title,
  value,
  id,
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
}: AppSelectProps) {
  const [open, setOpen] = useState(false);
  const rootRef = useRef<HTMLDivElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const suppressNextToggleRef = useRef(false);
  const pointerCommittedRef = useRef(false);
  const guardTimerRef = useRef<number | null>(null);

  const options = useMemo(() => parseOptions(children), [children]);
  const normalizedValue = String(value ?? "");
  const selectedOption = options.find((option) => option.value === normalizedValue) ?? options[0] ?? null;

  useEffect(() => {
    return () => {
      if (guardTimerRef.current != null) {
        window.clearTimeout(guardTimerRef.current);
      }
    };
  }, []);

  useEffect(() => {
    if (!open) {
      return;
    }

    function handlePointerDown(event: PointerEvent) {
      if (!rootRef.current?.contains(event.target as Node)) {
        setOpen(false);
      }
    }

    function handleEscape(event: KeyboardEvent) {
      if (event.key === "Escape") {
        setOpen(false);
        buttonRef.current?.focus();
      }
    }

    document.addEventListener("pointerdown", handlePointerDown);
    document.addEventListener("keydown", handleEscape);
    return () => {
      document.removeEventListener("pointerdown", handlePointerDown);
      document.removeEventListener("keydown", handleEscape);
    };
  }, [open]);

  function commit(nextValue: string) {
    suppressNextToggleRef.current = true;
    onChange?.({ target: { value: nextValue } });
    setOpen(false);
    buttonRef.current?.focus({ preventScroll: true });
    if (guardTimerRef.current != null) {
      window.clearTimeout(guardTimerRef.current);
    }
    guardTimerRef.current = window.setTimeout(() => {
      suppressNextToggleRef.current = false;
      pointerCommittedRef.current = false;
      guardTimerRef.current = null;
    }, 220);
  }

  return (
    <div ref={rootRef} className="relative">
      <button
        ref={buttonRef}
        id={id}
        type="button"
        title={title ?? (typeof selectedOption?.label === "string" ? selectedOption.label : undefined)}
        aria-label={ariaLabel}
        aria-labelledby={ariaLabelledBy}
        aria-haspopup="listbox"
        aria-expanded={open}
        disabled={disabled}
        onClick={(event) => {
          event.preventDefault();
          event.stopPropagation();
          if (disabled) return;
          if (suppressNextToggleRef.current) {
            suppressNextToggleRef.current = false;
            return;
          }
          setOpen((current) => !current);
        }}
        onKeyDown={(event) => {
          if (disabled) {
            return;
          }
          if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ") {
            event.preventDefault();
            setOpen(true);
          }
        }}
        className={cx(
          "flex w-full items-center gap-2 text-left transition-colors",
          "hover:border-primary/30 hover:bg-surface/70",
          "focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20",
          "disabled:cursor-not-allowed disabled:opacity-60",
          open && "border-primary/45 bg-surface/85 shadow-[0_16px_36px_-24px] shadow-black/70",
          className,
        )}
        style={{ paddingRight: "2.5rem" }}
      >
        <span className="min-w-0 flex-1 truncate">
          {selectedOption?.label ?? <span className="text-foreground-secondary/60">&nbsp;</span>}
        </span>
        <ChevronDown
          size={14}
          className="pointer-events-none text-foreground-secondary/60"
          style={{
            position: "absolute",
            right: 12,
            top: "50%",
            transform: open ? "translateY(-50%) rotate(180deg)" : "translateY(-50%) rotate(0deg)",
            transition: "transform 180ms ease",
          }}
        />
      </button>

      {open && !disabled && (
        <div
          className={cx(
            "hf-elevated-menu absolute left-0 z-50 w-full overflow-hidden rounded-xl border p-1 backdrop-blur-xl",
            placement === "top" ? "bottom-full mb-1.5" : "top-full mt-1.5",
            menuClassName,
          )}
        >
          <div role="listbox" className="max-h-64 overflow-y-auto">
            {options.map((option) => {
              const selected = option.value === normalizedValue;
              return (
                <button
                  key={option.key}
                  type="button"
                  role="option"
                  aria-selected={selected}
                  disabled={option.disabled}
                  onPointerDown={(event) => {
                    event.preventDefault();
                    event.stopPropagation();
                    if (option.disabled) return;
                    pointerCommittedRef.current = true;
                    commit(option.value);
                  }}
                  onClick={(event) => {
                    event.preventDefault();
                    event.stopPropagation();
                    if (option.disabled || pointerCommittedRef.current) {
                      pointerCommittedRef.current = false;
                      return;
                    }
                    commit(option.value);
                  }}
                  data-selected={selected ? "true" : undefined}
                  className={cx(
                    "hf-menu-item flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors",
                    selected ? "text-foreground" : "text-foreground-secondary hover:text-foreground",
                    option.disabled && "cursor-not-allowed opacity-45",
                    optionClassName,
                  )}
                >
                  <span className={cx("min-w-0 flex-1 truncate", selected && "font-medium")}>{option.label}</span>
                  {selected && <Check size={14} className="shrink-0 text-primary" />}
                </button>
              );
            })}
          </div>
        </div>
      )}
    </div>
  );
}