shadimage
Beste UI Components
componentinspector-linksource: ok · hit

Inspector Link

Settings row for a destination: the row reads the URL with its scheme stripped and marks whether it leaves the tab, and opens an editor holding an optional page menu, the URL field and the target and nofollow switches.

npx shadcn add "https://ui.beste.co/component/r/inspector-link.json"
free

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

Dependencies

Bundle weight · light
clsxtailwind-mergelucide-react

Registry dependencies

popoverswitchselect

Files4 · source cached 10/8/2026, 3:38:26 PM

registry-components/inspector-link/inspector-link.tsx
"use client";

import { ArrowUpRightIcon, LinkIcon, type LucideIcon } from "lucide-react";
import * as React from "react";
import { InspectorInput } from "@/components/beste/component/inspector-input";
import { InspectorSelect } from "@/components/beste/component/inspector-select";
import { InspectorSwitch } from "@/components/beste/component/inspector-switch";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";

/** Surface treatment of the row. Mirrors inspector-slider. */
type Tone = "muted" | "outline" | "ghost";

/** Row height preset. Mirrors inspector-slider. */
type Size = "sm" | "default" | "lg";

/** The row itself presses, so it answers hover like any other trigger in the family. */
const toneStyles: Record<Tone, string> = {
  muted: "border border-transparent bg-muted hover:bg-muted-foreground/15",
  outline: "border border-border hover:bg-muted",
  ghost: "border border-transparent hover:border-border hover:bg-muted",
};

const sizeStyles: Record<Size, string> = {
  sm: "[--inspector-height:--spacing(8)] [--inspector-pad:--spacing(2.5)]",
  default: "[--inspector-height:--spacing(9)] [--inspector-pad:--spacing(3)]",
  lg: "[--inspector-height:--spacing(11)] [--inspector-pad:--spacing(4)]",
};

/** A destination, in the pieces an anchor takes. */
interface LinkValue {
  /** Where it goes. A path, a full URL, a `mailto:`, an anchor. */
  href?: string;
  /** Open in a new tab, which is `target="_blank"`. */
  newTab?: boolean;
  /** Add `nofollow` to the anchor's `rel`. */
  nofollow?: boolean;
}

/** An internal destination offered instead of a typed URL. */
interface InspectorLinkPage {
  /** The href this page resolves to. */
  value: string;
  /** How it reads in the menu. Falls back to `value`. */
  label?: string;
}

const CUSTOM = "__custom__";

/**
 * How a destination reads on the row. A path stays as it is, since that is what
 * the reader typed; a full URL drops its scheme and any `www.`, which is noise in
 * a column this narrow.
 */
function summarize(href: string): string {
  const trimmed = href.trim();
  if (!/^https?:\/\//i.test(trimmed)) return trimmed;
  return trimmed.replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/$/, "");
}

interface InspectorLinkProps {
  /** Label rendered on the left, inside the row. */
  label: string;
  /** Optional leading icon shown before the label. */
  icon?: LucideIcon;

  /** Controlled value. Pair it with `onValueChange`. */
  value?: LinkValue;
  /** Initial value in uncontrolled mode. */
  defaultValue?: LinkValue;
  /** Fires on every keystroke in the URL field and on every toggle. */
  onValueChange?: (value: LinkValue) => void;
  /**
   * Fires once an edit is finished — the URL field left, a switch flipped, the
   * link removed. Use it for work too expensive to run per keystroke.
   */
  onValueCommit?: (value: LinkValue) => void;
  /** Fires when the editor opens or closes. */
  onOpenChange?: (open: boolean) => void;

  /**
   * Destinations inside the site. Passing them adds a menu above the URL field,
   * so the common case is a choice rather than a typed path.
   */
  pages?: (string | InspectorLinkPage)[];
  /** Offer the nofollow toggle. */
  allowNofollow?: boolean;
  /** Ghost text in the URL field. */
  placeholder?: string;
  /**
   * What the row reads while there is no destination.
   * @defaultValue "None" */
  emptyLabel?: string;

  /** Block interaction and dim the row. */
  disabled?: boolean;
  /**
   * Surface treatment: filled (default), hairline outline, or bare until hover.
   * @defaultValue "muted" */
  tone?: Tone;
  /**
   * Row height preset.
   * @defaultValue "default" */
  size?: Size;

  className?: string;
  /** Accessible name. Falls back to `label`. */
  "aria-label"?: string;
}

export const inspectorLinkDemo: InspectorLinkProps = {
  label: "Link",
  className: "w-72",
  defaultValue: { href: "https://beste.co/pricing", newTab: true },
  pages: [
    { value: "/", label: "Home" },
    { value: "/pricing", label: "Pricing" },
    { value: "/blog", label: "Blog" },
  ],
  allowNofollow: true,
};

export function InspectorLink({
  label,
  icon: Icon,
  value: valueProp,
  defaultValue,
  onValueChange,
  onValueCommit,
  onOpenChange,
  pages,
  allowNofollow = false,
  placeholder = "https://",
  emptyLabel = "None",
  disabled = false,
  tone = "muted",
  size = "default",
  className,
  "aria-label": ariaLabel,
}: InspectorLinkProps) {
  const [internalValue, setInternalValue] = React.useState<LinkValue>(defaultValue ?? {});
  const value = valueProp ?? internalValue;
  const href = value.href ?? "";

  const valueRef = React.useRef(value);
  valueRef.current = value;

  const write = (patch: Partial<LinkValue>, complete: boolean) => {
    const next = { ...valueRef.current, ...patch };
    valueRef.current = next;
    if (valueProp === undefined) setInternalValue(next);
    onValueChange?.(next);
    if (complete) onValueCommit?.(next);
  };

  const pageOptions = React.useMemo(
    () =>
      (pages ?? []).map((entry) => (typeof entry === "string" ? { value: entry } : entry)),
    [pages],
  );

  /*
   * The menu holds the pages plus one entry for anything else. Sitting on
   * "Custom URL" whenever the href is not a page keeps the two controls telling
   * the same story: pick a page and the field fills in, type over it and the menu
   * falls back on its own.
   */
  const pageValue = pageOptions.some((page) => page.value === href) ? href : CUSTOM;

  return (
    <Popover onOpenChange={onOpenChange}>
      {/*
        The row is the trigger, so the editor can take the trigger width and open
        flush with it. What the row shows is the destination, which is the one
        thing worth reading without opening anything.
      */}
      <PopoverTrigger asChild>
        <button
          type="button"
          disabled={disabled}
          aria-label={ariaLabel}
          data-slot="inspector-link"
          data-disabled={disabled}
          className={cn(
            "group/inspector-link flex w-full cursor-pointer items-center gap-2 text-left",
            "h-(--inspector-height) rounded-(--inspector-radius) px-(--inspector-pad)",
            "[--inspector-radius:var(--radius-xl)] transition-colors",
            "outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
            "data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
            sizeStyles[size],
            toneStyles[tone],
            className,
          )}
        >
          <span
            data-slot="inspector-link-label"
            className="flex shrink-0 items-center gap-1.5 text-sm font-medium text-foreground/70 select-none"
          >
            {Icon ? <Icon className="size-4 shrink-0" /> : null}
            <span className="truncate">{label}</span>
          </span>

          <span
            data-slot="inspector-link-value"
            className={cn(
              "ml-auto min-w-0 truncate text-sm font-medium select-none",
              href ? "text-foreground" : "text-foreground/70",
            )}
          >
            {href ? summarize(href) : emptyLabel}
          </span>

          {/*
            The mark is the destination's one property worth seeing from outside:
            a link that leaves the tab behaves differently, and finding that out
            by pressing it is the wrong time.
          */}
          {href ? (
            <span aria-hidden="true" className="shrink-0 text-foreground/70">
              {value.newTab ? (
                <ArrowUpRightIcon className="size-4" />
              ) : (
                <LinkIcon className="size-4" />
              )}
            </span>
          ) : null}
        </button>
      </PopoverTrigger>

      <PopoverContent
        align="start"
        side="bottom"
        sideOffset={8}
        className="w-[var(--radix-popover-trigger-width)] min-w-64 p-2"
      >
        <div className="flex flex-col gap-1">
          {pageOptions.length > 0 ? (
            <InspectorSelect
              label="Page"
              size="sm"
              options={[
                { value: CUSTOM, label: "Custom URL" },
                ...pageOptions.map((page) => ({
                  value: page.value,
                  label: page.label ?? page.value,
                })),
              ]}
              value={pageValue}
              onValueChange={(next) => write({ href: next === CUSTOM ? "" : next }, true)}
            />
          ) : null}

          <InspectorInput
            label="URL"
            size="sm"
            type="url"
            placeholder={placeholder}
            value={href}
            onValueChange={(next) => write({ href: next }, false)}
            onValueCommit={(next) => write({ href: next }, true)}
          />

          <InspectorSwitch
            label="New tab"
            size="sm"
            checked={value.newTab ?? false}
            onCheckedChange={(next) => write({ newTab: next }, true)}
          />

          {allowNofollow ? (
            <InspectorSwitch
              label="Nofollow"
              size="sm"
              checked={value.nofollow ?? false}
              onCheckedChange={(next) => write({ nofollow: next }, true)}
            />
          ) : null}
        </div>

        {/*
          Clearing the destination is its own act, not an empty field: it drops the
          switches with it, so a removed link cannot leave a stray `target` behind.
        */}
        {href ? (
          <button
            type="button"
            onClick={() => write({ href: "", newTab: false, nofollow: false }, true)}
            data-slot="inspector-link-clear"
            className="mt-2 flex w-full cursor-pointer items-center justify-center border-t border-border pt-2 text-sm font-medium text-foreground/70 transition-colors outline-none select-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
          >
            Remove Link
          </button>
        ) : null}
      </PopoverContent>
    </Popover>
  );
}
registry-components/inspector-input/inspector-input.tsx
"use client";

import type { LucideIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";

/** Surface treatment of the row. Mirrors inspector-slider. */
type Tone = "muted" | "outline" | "ghost";

/** Row height preset. Mirrors inspector-slider. */
type Size = "sm" | "default" | "lg";

/** The subset of input types that reads sensibly as a settings row. */
type InspectorInputType = "text" | "number" | "email" | "url" | "tel" | "password" | "search";

const toneStyles: Record<Tone, string> = {
  muted: "border border-transparent bg-muted",
  outline: "border border-border",
  ghost: "border border-transparent hover:border-border",
};

const sizeStyles: Record<Size, string> = {
  sm: "[--inspector-height:--spacing(8)] [--inspector-pad:--spacing(2.5)]",
  default: "[--inspector-height:--spacing(9)] [--inspector-pad:--spacing(3)]",
  lg: "[--inspector-height:--spacing(11)] [--inspector-pad:--spacing(4)]",
};

/**
 * Number inputs bring spinners that fight the row's geometry, so they are turned
 * off and the row is driven by typing alone.
 */
const NO_SPINNERS =
  "[appearance:textfield] [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none";

interface InspectorInputProps {
  /** Label rendered on the left, inside the row. */
  label: string;
  /** Optional leading icon shown before the label. */
  icon?: LucideIcon;

  /** Controlled value. Pair it with `onValueChange`. */
  value?: string;
  /** Initial value in uncontrolled mode. */
  defaultValue?: string;
  /** Fires on every keystroke. */
  onValueChange?: (value: string) => void;
  /**
   * Fires once the edit is finished — Enter, or leaving the field — and only when
   * the value actually changed. Use it for work too expensive to run per
   * keystroke, such as a request or a re-render of something heavy.
   */
  onValueCommit?: (value: string) => void;

  /** Ghost text shown while the field is empty. */
  placeholder?: string;
  /**
   * Input type. `number` also hides the spinners and switches the on-screen
   * keyboard to a numeric one.
   * @defaultValue "text" */
  type?: InspectorInputType;
  /** Muted unit after the value, e.g. "px", "%", "ms". */
  suffix?: string;
  /**
   * Which end the text sits at. Short values read better tucked against the
   * right, the way the rest of the family shows a value; long ones, a URL say,
   * are easier to follow from the left.
   * @defaultValue "end" */
  align?: "start" | "end";
  maxLength?: number;

  /** Block interaction and dim the row. */
  disabled?: boolean;
  /** Show the value but refuse edits. */
  readOnly?: boolean;
  /**
   * Surface treatment: filled (default), hairline outline, or bare until hover.
   * @defaultValue "muted" */
  tone?: Tone;
  /**
   * Row height preset.
   * @defaultValue "default" */
  size?: Size;

  /** Name of the input, so the row can take part in a form. */
  name?: string;
  /** Id of the input. The row is a `<label>`, so this is only for outside use. */
  id?: string;
  className?: string;
  /** Accessible name. Falls back to the visible label. */
  "aria-label"?: string;
}

export const inspectorInputDemo: InspectorInputProps = {
  label: "Class name",
  className: "w-72",
  defaultValue: "hero-section",
  placeholder: "Optional",
};

export function InspectorInput({
  label,
  icon: Icon,
  value: valueProp,
  defaultValue,
  onValueChange,
  onValueCommit,
  placeholder,
  type = "text",
  suffix,
  align = "end",
  maxLength,
  disabled = false,
  readOnly = false,
  tone = "muted",
  size = "default",
  name,
  id,
  className,
  "aria-label": ariaLabel,
}: InspectorInputProps) {
  const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
  const value = valueProp ?? internalValue;

  /** Last value handed to `onValueCommit`, so a commit only reports real edits. */
  const committedRef = React.useRef(value);

  const setValue = (next: string) => {
    if (valueProp === undefined) setInternalValue(next);
    onValueChange?.(next);
  };

  const commit = (next: string) => {
    if (next === committedRef.current) return;
    committedRef.current = next;
    onValueCommit?.(next);
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "Enter") {
      // Committing on Enter, and not submitting: a settings row is rarely the
      // only field in its form.
      event.preventDefault();
      commit(value);
    } else if (event.key === "Escape") {
      event.preventDefault();
      setValue(committedRef.current);
    }
  };

  return (
    // The row is a label, so a press anywhere on it — the text included — puts the
    // caret in the field, and the visible label is the field's accessible name.
    <label
      data-slot="inspector-input"
      data-disabled={disabled}
      className={cn(
        "group/inspector-input flex items-center gap-2",
        "h-(--inspector-height) rounded-(--inspector-radius) px-(--inspector-pad)",
        "[--inspector-radius:var(--radius-xl)]",
        "cursor-text has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring/50 has-[input:focus-visible]:ring-offset-2 has-[input:focus-visible]:ring-offset-background",
        "data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
        sizeStyles[size],
        toneStyles[tone],
        className,
      )}
    >
      <span
        data-slot="inspector-input-label"
        className="flex shrink-0 items-center gap-1.5 text-sm font-medium text-foreground/70 select-none"
      >
        {Icon ? <Icon className="size-4 shrink-0" /> : null}
        <span className="truncate">{label}</span>
      </span>

      <input
        type={type}
        value={value}
        placeholder={placeholder}
        maxLength={maxLength}
        disabled={disabled}
        readOnly={readOnly}
        name={name}
        id={id}
        aria-label={ariaLabel}
        inputMode={type === "number" ? "decimal" : undefined}
        spellCheck={type === "text" ? undefined : false}
        autoComplete={type === "text" ? "off" : undefined}
        onChange={(event) => setValue(event.target.value)}
        onBlur={() => commit(value)}
        onKeyDown={handleKeyDown}
        data-slot="inspector-input-field"
        className={cn(
          "min-w-0 flex-1 bg-transparent text-sm font-medium text-foreground",
          "outline-none placeholder:font-normal placeholder:text-muted-foreground",
          "read-only:cursor-default",
          align === "end" ? "text-right" : "text-left",
          type === "number" && "font-mono tabular-nums",
          type === "number" && NO_SPINNERS,
        )}
      />

      {suffix ? (
        <span
          data-slot="inspector-input-suffix"
          aria-hidden="true"
          className="shrink-0 text-sm font-medium text-foreground/70 select-none"
        >
          {suffix}
        </span>
      ) : null}
    </label>
  );
}
registry-components/inspector-switch/inspector-switch.tsx
"use client";

import type { LucideIcon } from "lucide-react";
import * as React from "react";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";

/** Surface treatment of the row. Mirrors inspector-slider. */
type Tone = "muted" | "outline" | "ghost";

/** Row height preset. Mirrors inspector-slider. */
type Size = "sm" | "default" | "lg";

const toneStyles: Record<Tone, string> = {
  muted: "border border-transparent bg-muted",
  outline: "border border-border",
  ghost: "border border-transparent hover:border-border",
};

const sizeStyles: Record<Size, string> = {
  sm: "[--inspector-height:--spacing(8)] [--inspector-pad:--spacing(2.5)]",
  default: "[--inspector-height:--spacing(9)] [--inspector-pad:--spacing(3)]",
  lg: "[--inspector-height:--spacing(11)] [--inspector-pad:--spacing(4)]",
};

/**
 * Two corrections to the shadcn switch, so it sits properly in a tinted row.
 *
 * An unchecked switch is `bg-input`, which is close enough to `bg-muted` to go
 * quiet on this surface, so the off state gets a hairline instead. And its own
 * focus ring is dropped in favour of one on the row, which reads better around a
 * control this small — and gives the row a focus indicator in flavours of the
 * switch that ship without one.
 */
const SWITCH_IN_ROW = "data-[state=unchecked]:border-border focus-visible:ring-0";

interface InspectorSwitchProps {
  /** Label rendered on the left, inside the row. */
  label: string;
  /** Optional leading icon shown before the label. */
  icon?: LucideIcon;

  /**
   * Controlled state. Named after the switch rather than the family's `value`,
   * because every switch in this codebase already speaks
   * `checked`/`onCheckedChange`.
   */
  checked?: boolean;
  /** Initial state in uncontrolled mode. */
  defaultChecked?: boolean;
  onCheckedChange?: (checked: boolean) => void;

  /** Block interaction and dim the row. */
  disabled?: boolean;
  /**
   * Surface treatment: filled (default), hairline outline, or bare until hover.
   * @defaultValue "muted" */
  tone?: Tone;
  /**
   * Row height preset.
   * @defaultValue "default" */
  size?: Size;

  /** Name of the underlying control, so the row can take part in a form. */
  name?: string;
  /** Id of the switch. Generated when omitted, to tie the label to it. */
  id?: string;
  className?: string;
  /** Accessible name. Falls back to the visible label. */
  "aria-label"?: string;
}

export const inspectorSwitchDemo: InspectorSwitchProps = {
  label: "Parallax",
  className: "w-72",
  defaultChecked: true,
};

export function InspectorSwitch({
  label,
  icon: Icon,
  checked: checkedProp,
  defaultChecked,
  onCheckedChange,
  disabled = false,
  tone = "muted",
  size = "default",
  name,
  id,
  className,
  "aria-label": ariaLabel,
}: InspectorSwitchProps) {
  const [internalChecked, setInternalChecked] = React.useState(defaultChecked ?? false);
  const checked = checkedProp ?? internalChecked;

  const generatedId = React.useId();
  const switchId = id ?? generatedId;

  const handleCheckedChange = (next: boolean) => {
    if (checkedProp === undefined) setInternalChecked(next);
    onCheckedChange?.(next);
  };

  return (
    <div
      data-slot="inspector-switch"
      data-disabled={disabled}
      className={cn(
        "group/inspector-switch flex items-center gap-2",
        "h-(--inspector-height) rounded-(--inspector-radius) px-(--inspector-pad)",
        "[--inspector-radius:var(--radius-xl)]",
        "has-[[data-slot=switch]:focus-visible]:ring-2 has-[[data-slot=switch]:focus-visible]:ring-ring/50 has-[[data-slot=switch]:focus-visible]:ring-offset-2 has-[[data-slot=switch]:focus-visible]:ring-offset-background",
        "data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
        sizeStyles[size],
        toneStyles[tone],
        className,
      )}
    >
      {/*
        `htmlFor` rather than wrapping the switch: the switch renders a button and,
        with `name`, a hidden checkbox too, and a wrapping label would have to
        guess which of the two it labels. Giving the label the row's spare width
        means a press almost anywhere toggles, without any of that ambiguity.
      */}
      <label
        htmlFor={switchId}
        data-slot="inspector-switch-label"
        className="flex min-w-0 flex-1 cursor-pointer items-center gap-1.5 text-sm font-medium text-foreground/70 select-none"
      >
        {Icon ? <Icon className="size-4 shrink-0" /> : null}
        <span className="truncate">{label}</span>
      </label>

      <Switch
        id={switchId}
        name={name}
        checked={checked}
        onCheckedChange={handleCheckedChange}
        disabled={disabled}
        aria-label={ariaLabel}
        className={cn("shrink-0 cursor-pointer", SWITCH_IN_ROW)}
      />
    </div>
  );
}
registry-components/inspector-select/inspector-select.tsx
"use client";

import type { LucideIcon } from "lucide-react";
import * as React from "react";
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectSeparator,
  SelectTrigger,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";

/** Surface treatment of the row. Mirrors inspector-slider. */
type Tone = "muted" | "outline" | "ghost";

/** Row height preset. Mirrors inspector-slider. */
type Size = "sm" | "default" | "lg";

/*
 * The hover and open surfaces are translucent tints rather than `bg-accent`,
 * which is the same colour as `bg-muted` in a light theme and would read as no
 * change at all. Tinting also blends when the row sits on a card.
 *
 * Every surface is declared twice, plain and `dark:`. That is not redundant: the
 * shadcn select trigger ships its own `dark:bg-input/30` and
 * `dark:hover:bg-input/50`, and a `dark:`-prefixed rule outranks an unprefixed
 * one, so a plain `bg-muted` alone would lose in dark mode.
 */
const toneStyles: Record<Tone, string> = {
  muted:
    "border-transparent bg-muted dark:bg-muted hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/15 data-[state=open]:bg-muted-foreground/20 dark:data-[state=open]:bg-muted-foreground/20",
  outline:
    "border-border bg-transparent dark:bg-transparent hover:bg-muted dark:hover:bg-muted data-[state=open]:bg-muted dark:data-[state=open]:bg-muted",
  ghost:
    "border-transparent bg-transparent dark:bg-transparent hover:bg-muted dark:hover:bg-muted data-[state=open]:bg-muted dark:data-[state=open]:bg-muted",
};

const sizeStyles: Record<Size, string> = {
  sm: "[--inspector-height:--spacing(8)] [--inspector-pad:--spacing(2.5)]",
  default: "[--inspector-height:--spacing(9)] [--inspector-pad:--spacing(3)]",
  lg: "[--inspector-height:--spacing(11)] [--inspector-pad:--spacing(4)]",
};

interface InspectorSelectOption {
  /** Value reported on selection. An empty string is not a valid option value. */
  value: string;
  /** Text shown in the row and in the menu. Falls back to `value`. */
  label?: string;
  /** Second line under the label, in the menu only. */
  description?: string;
  /** Leading icon, shown in the menu and in the row once selected. */
  icon?: LucideIcon;
  /** Leading colour dot in any CSS colour — palettes, themes, brand colors. */
  swatch?: string;
  /**
   * Heading this option sits under. Consecutive options that share a group form
   * one section, so the authored order is always preserved.
   */
  group?: string;
  disabled?: boolean;
}

/** One section of the menu: a run of options under an optional heading. */
interface OptionSection {
  label?: string;
  options: InspectorSelectOption[];
}

/**
 * Normalize the options and slice them into sections. Kept outside the component
 * so it stays a pure function of its input and can be memoized on `options`.
 */
function buildSections(entries: (string | InspectorSelectOption)[]): OptionSection[] {
  const sections: OptionSection[] = [];

  for (const entry of entries) {
    const option = typeof entry === "string" ? { value: entry } : entry;
    // An empty value is the "nothing selected" sentinel and throws if used for
    // an item, so drop the entry rather than take the whole menu down with it.
    if (!option.value) continue;

    const current = sections[sections.length - 1];
    if (current && current.label === option.group) {
      current.options.push(option);
    } else {
      sections.push({ label: option.group, options: [option] });
    }
  }

  return sections;
}

interface InspectorSelectProps {
  /** Label rendered on the left, inside the row. */
  label: string;
  /** Optional leading icon shown before the label. */
  icon?: LucideIcon;

  /** Plain strings for simple lists, or objects for icons, swatches and groups. */
  options?: (string | InspectorSelectOption)[];

  /** Controlled value. Pair it with `onValueChange`. */
  value?: string;
  /** Initial value in uncontrolled mode. */
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Fires when the menu opens or closes. */
  onOpenChange?: (open: boolean) => void;

  /**
   * Shown in place of the value until something is selected.
   * @defaultValue "Select" */
  placeholder?: string;
  /**
   * Menu text when there is nothing to choose from.
   * @defaultValue "No options" */
  emptyMessage?: string;
  /**
   * Menu width: match the row, or size to the longest option.
   * @defaultValue "trigger" */
  menuWidth?: "trigger" | "auto";

  /** Block interaction and dim the row. */
  disabled?: boolean;
  /**
   * Surface treatment: filled (default), hairline outline, or bare until hover.
   * @defaultValue "muted" */
  tone?: Tone;
  /**
   * Row height preset.
   * @defaultValue "default" */
  size?: Size;

  /** Name of the underlying form control, so the row can take part in a form. */
  name?: string;
  /** Id of the trigger, for an external `<label htmlFor>`. */
  id?: string;
  className?: string;
  /**
   * Accessible name. Omit it and the row reads as "{label}, {value}", which is
   * usually what you want — the visible label and the value are both announced.
   */
  "aria-label"?: string;
}

export const inspectorSelectDemo: InspectorSelectProps = {
  label: "Blend mode",
  className: "w-72",
  defaultValue: "screen",
  options: [
    { value: "normal", label: "Normal" },
    {
      value: "multiply",
      label: "Multiply",
      group: "Darken",
      description: "Keeps the darker pixels",
    },
    { value: "color-burn", label: "Color burn", group: "Darken" },
    {
      value: "screen",
      label: "Screen",
      group: "Lighten",
      description: "Keeps the lighter pixels",
    },
    { value: "color-dodge", label: "Color dodge", group: "Lighten" },
  ],
};

export function InspectorSelect({
  label,
  icon: Icon,
  options = [],
  value: valueProp,
  defaultValue,
  onValueChange,
  onOpenChange,
  placeholder = "Select",
  emptyMessage = "No options",
  menuWidth = "trigger",
  disabled = false,
  tone = "muted",
  size = "default",
  name,
  id,
  className,
  "aria-label": ariaLabel,
}: InspectorSelectProps) {
  const sections = React.useMemo(() => buildSections(options), [options]);

  // The row draws its own trigger content, so it has to know the value in both
  // modes. Keeping the fallback an empty string means the underlying select is
  // controlled from the first render and never switches modes mid-life.
  const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
  const value = valueProp ?? internalValue;

  const selected = React.useMemo(() => {
    if (!value) return undefined;
    for (const section of sections) {
      for (const option of section.options) {
        if (option.value === value) return option;
      }
    }
    return undefined;
  }, [sections, value]);

  const handleValueChange = (next: string | null) => {
    // Base UI reports null when a selection is cleared; this row never clears,
    // but guard it so a null can never reach `onValueChange`.
    if (next === null) return;
    if (valueProp === undefined) setInternalValue(next);
    onValueChange?.(next);
  };

  const SelectedIcon = selected?.icon;

  return (
    <Select
      value={value}
      onValueChange={handleValueChange}
      onOpenChange={onOpenChange}
      disabled={disabled}
      name={name}
    >
      <SelectTrigger
        id={id}
        size="default"
        aria-label={ariaLabel}
        className={cn(
          // Family tokens, mirrored from inspector-slider so a column of rows
          // lines up whatever the mix of controls.
          "[--inspector-radius:var(--radius-xl)]",
          sizeStyles[size],
          "w-full cursor-pointer justify-between gap-2 py-0 shadow-none select-none",
          "rounded-(--inspector-radius) px-(--inspector-pad) data-[size=default]:h-(--inspector-height)",
          "text-sm font-medium transition-colors",
          "focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          // The trigger appends the chevron as its own last child. Bring it up to
          // full opacity and turn it over while the menu is open.
          "[&>svg:last-child]:opacity-100 [&>svg:last-child]:transition-transform data-[state=open]:[&>svg:last-child]:rotate-180",
          toneStyles[tone],
          className
        )}
      >
        <span className="flex min-w-0 items-center gap-1.5 text-foreground/70">
          {Icon ? <Icon className="size-4 shrink-0" /> : null}
          <span className="truncate">{label}</span>
        </span>

        <span
          className={cn(
            "ml-auto flex min-w-0 items-center gap-1.5",
            selected ? "text-foreground" : "text-foreground/70"
          )}
        >
          {selected?.swatch ? (
            <span
              aria-hidden="true"
              className="size-3 shrink-0 rounded-full border border-border"
              style={{ backgroundColor: selected.swatch }}
            />
          ) : null}
          {SelectedIcon ? <SelectedIcon className="size-4 shrink-0" /> : null}
          <span className="truncate">
            {selected ? (selected.label ?? selected.value) : placeholder}
          </span>
        </span>
      </SelectTrigger>

      {/*
        No `sideOffset` of its own: the wrapper already nudges a popper-positioned
        menu down by 4px, so adding one on top only pushed the menu away from the
        row it belongs to. This is the gap every other select in the app has.
      */}
      <SelectContent
        position="popper"
        align="start"
        className={cn(
          "[--inspector-radius:var(--radius-xl)] rounded-(--inspector-radius)",
          // The menu is portaled out of the row, so it carries its own copy of
          // the radius token instead of inheriting one.
          menuWidth === "trigger" && "w-(--radix-select-trigger-width)"
        )}
      >
        {sections.length === 0 ? (
          <div className="px-2 py-1.5 text-sm text-foreground/70 select-none">{emptyMessage}</div>
        ) : (
          sections.map((section, index) => (
            <React.Fragment key={`${index}-${section.label ?? ""}`}>
              {index > 0 ? <SelectSeparator /> : null}
              <SelectGroup>
                {section.label ? (
                  <SelectLabel className="text-sm font-medium select-none">
                    {section.label}
                  </SelectLabel>
                ) : null}
                {section.options.map((option) => {
                  const OptionIcon = option.icon;
                  // A described option is two lines tall, so its leading mark
                  // lines up with the first line instead of the block.
                  const leadingAlign = option.description ? "mt-0.5" : undefined;

                  return (
                    <SelectItem
                      key={option.value}
                      value={option.value}
                      disabled={option.disabled}
                      className={cn(
                        "cursor-pointer rounded-md",
                        option.description && "items-start *:[span]:last:items-start"
                      )}
                    >
                      {option.swatch ? (
                        <span
                          aria-hidden="true"
                          className={cn(
                            "size-3 shrink-0 rounded-full border border-border",
                            leadingAlign
                          )}
                          style={{ backgroundColor: option.swatch }}
                        />
                      ) : null}
                      {OptionIcon ? (
                        <OptionIcon className={cn("size-4 shrink-0", leadingAlign)} />
                      ) : null}
                      <span className="flex min-w-0 flex-col gap-0.5">
                        <span className="truncate">{option.label ?? option.value}</span>
                        {option.description ? (
                          <span className="truncate text-sm text-foreground/70 select-none">
                            {option.description}
                          </span>
                        ) : null}
                      </span>
                    </SelectItem>
                  );
                })}
              </SelectGroup>
            </React.Fragment>
          ))
        )}
      </SelectContent>
    </Select>
  );
}

More from Beste UI Components

Browse all components in this library →