shadimage
ilinxa
blockaccount-switchersource: ok · hit

Account Switcher

Popover account and context switcher — active label trigger, switchable context list, and a footer slot for create or request actions.

npx shadcn add "https://ui.ilinxa.com/r/account-switcher.json"
free

Where it goes

footer

Interaction

tooltip

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

popoverseparator

Files10 · source cached 10/9/2026, 4:49:30 AM

src/registry/components/navigation/account-switcher/account-switcher.tsx
"use client";

import { useCallback, useId, useMemo } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { useControllableState } from "./hooks/use-controllable-state";
import { enforceUniqueKeys } from "./lib/enforce-unique-keys";
import { resolveActiveItem } from "./lib/resolve-active-item";
import { EmptyPlaceholder } from "./parts/empty-placeholder";
import { SwitcherItemRow } from "./parts/switcher-item-row";
import {
  SwitcherTriggerContent,
  composeSwitcherTriggerAriaLabel,
  switcherTriggerClassName,
} from "./parts/switcher-trigger";
import type { AccountSwitcherProps, SwitcherItem } from "./types";

const DEFAULT_ARIA_LABEL = "Switch account context";

/**
 * Account Switcher — popover-with-switchable-items primitive.
 *
 * See [`docs/procomps/account-switcher-procomp/`](../../../../../docs/procomps/account-switcher-procomp/)
 * for description + plan + guide. Locks L1–L14, all PQs at default per
 * 2026-05-23 GATE 2 close.
 */
export function AccountSwitcher(props: AccountSwitcherProps) {
  const {
    items,
    activeKey,
    onSelect,
    fallbackActiveItem,
    footerSlot,
    isCollapsed = false,
    collapsedPopoverSide = "right",
    "aria-label": ariaLabel = DEFAULT_ARIA_LABEL,
    ariaCurrent = "true",
    open: openProp,
    defaultOpen,
    onOpenChange,
    className,
  } = props;

  const listboxId = useId();

  const [open, setOpen] = useControllableState<boolean>({
    value: openProp,
    defaultValue: defaultOpen ?? false,
    onChange: onOpenChange,
    componentName: "account-switcher",
    valuePropName: "open",
  });

  // L3 + Q2 — dev-warn + strip duplicates once per items reference change.
  const dedupedItems = useMemo(() => enforceUniqueKeys(items), [items]);

  const activeResolution = useMemo(
    () => resolveActiveItem(dedupedItems, activeKey, fallbackActiveItem),
    [dedupedItems, activeKey, fallbackActiveItem],
  );

  const activeItem = activeResolution.kind === "empty" ? null : activeResolution.item;

  // F-cross-13 guard at the shadcn-primitive boundary. The consumer's
  // `onOpenChange` is shielded — `useControllableState` always calls it with
  // the validated `boolean`. Plan §10.
  const handlePrimitiveOpenChange = useCallback(
    (next: unknown) => {
      if (typeof next !== "boolean") return;
      setOpen(next);
    },
    [setOpen],
  );

  // L6 — active-item clicks close the popover but do NOT fire onSelect.
  const handleItemClick = useCallback(
    (item: SwitcherItem) => {
      if (item.key === activeItem?.key) {
        setOpen(false);
        return;
      }
      onSelect(item);
      setOpen(false);
    },
    [activeItem?.key, onSelect, setOpen],
  );

  // Empty-state branch (Q1)
  if (activeResolution.kind === "empty") {
    return (
      <EmptyPlaceholder
        ariaLabel={ariaLabel}
        isCollapsed={isCollapsed}
        className={className}
      />
    );
  }

  const popoverSide = isCollapsed ? collapsedPopoverSide : "bottom";
  const popoverWidthStyle = isCollapsed
    ? undefined
    : ({ width: "var(--radix-popover-trigger-width)" } as React.CSSProperties);

  return (
    <Popover open={open} onOpenChange={handlePrimitiveOpenChange}>
      {/* v0.1.1 (F-cross-13 path-b): no `asChild` — Base UI's PopoverTrigger
          rejects it. The trigger IS the combobox button: both backends render
          a native <button> and pass native DOM props straight through. */}
      <PopoverTrigger
        type="button"
        role="combobox"
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-controls={listboxId}
        aria-label={composeSwitcherTriggerAriaLabel(ariaLabel, activeItem)}
        className={switcherTriggerClassName({ isCollapsed, className })}
      >
        <SwitcherTriggerContent activeItem={activeItem} isCollapsed={isCollapsed} />
      </PopoverTrigger>
      <PopoverContent
        side={popoverSide}
        align="start"
        sideOffset={4}
        // No collisionPadding — Radix-only prop; Base UI's PopoverContent
        // rejects it (F-cross-13). Both backends still collision-flip.
        style={popoverWidthStyle}
        className={cn(
          "w-auto min-w-56 gap-0 p-1",
          // override the primitive's baked-in w-72; Radix exposes
          // --radix-popover-trigger-width, Base UI --anchor-width (F-cross-13)
          !isCollapsed && "w-(--radix-popover-trigger-width,var(--anchor-width))",
        )}
      >
        <ul id={listboxId} role="listbox" aria-label={ariaLabel} className="flex flex-col gap-0.5">
          {dedupedItems.map((item) => (
            <SwitcherItemRow
              key={item.key}
              item={item}
              isActive={item.key === activeItem?.key}
              ariaCurrent={ariaCurrent}
              onSelect={() => handleItemClick(item)}
            />
          ))}
        </ul>
        {footerSlot ? (
          <>
            <Separator className="my-1" />
            <div className="px-1 pb-1">{footerSlot}</div>
          </>
        ) : null}
      </PopoverContent>
    </Popover>
  );
}
src/registry/components/navigation/account-switcher/index.ts
export { AccountSwitcher } from "./account-switcher";
export type {
  AccountSwitcherProps,
  AccountSwitcherAriaCurrent,
  CollapsedPopoverSide,
  SwitcherItem,
} from "./types";
src/registry/components/navigation/account-switcher/types.ts
import type { ComponentType, ReactNode } from "react";

/**
 * Single item in the switcher list.
 *
 * Dual-entry items for the same conceptual account (e.g., business-mode +
 * cms-sub-mode of the same business) use distinct keys like `biz-acme` +
 * `cms-biz-acme`. Library enforces key uniqueness at render (L3 / Q2).
 */
export interface SwitcherItem {
  /** Stable unique key. Used for active resolution + React reconciliation. */
  key: string;
  /** Trigger + row label. Library does NOT i18n; consumer pre-translates. */
  label: string;
  /**
   * Optional icon. Library accepts both ReactNode (already-rendered JSX) and
   * ComponentType (lucide-react icons, custom icon components, etc.) so
   * consumers aren't forced into a single icon library (I-4).
   */
  icon?: ReactNode | ComponentType<{ className?: string }>;
  /**
   * Optional href. The switcher fires `onSelect(item)` regardless of href —
   * consumer wires routing inside `onSelect` (router.push for SPA, anchor for
   * SSR fallbacks, or both). Library does not render `<a>` tags itself.
   */
  href?: string;
}

/**
 * Value applied to the active item's `aria-current` attribute. Default
 * `"true"` (generic active-state semantic, correct for a switcher per L14).
 * Consumers using the switcher as primary navigation pick `"page"`;
 * stepper-style usages pick `"step"`. Pass `false` to omit the attribute.
 */
export type AccountSwitcherAriaCurrent =
  | "true"
  | "page"
  | "step"
  | "location"
  | "date"
  | "time"
  | false;

/**
 * Side of the trigger the popover opens to when `isCollapsed` is true.
 * Defaults to `"right"` (collision-aware auto-flip applied by Radix Popover).
 * Override to `"left"` for right-edge sidebars, etc. (PQ1).
 */
export type CollapsedPopoverSide = "right" | "left" | "top" | "bottom";

export interface AccountSwitcherProps {
  /** Ordered list. Consumer controls ordering (L2). */
  items: ReadonlyArray<SwitcherItem>;
  /**
   * Currently-active item's key. When null OR not found in items, falls
   * back to `fallbackActiveItem` (if provided) then to `items[0]`. Empty
   * items + no fallback → disabled placeholder button (L4, Q1).
   */
  activeKey: string | null;
  /** Fires on item click. Active-item clicks are no-ops at library level (L6). */
  onSelect: (item: SwitcherItem) => void;

  /** Shown in trigger when `activeKey` doesn't resolve (L4, I-1). */
  fallbackActiveItem?: SwitcherItem;
  /** Rendered below items, separated by divider when present (L5, Q7). */
  footerSlot?: ReactNode;
  /** When true, trigger collapses to icon-only mode (L10). */
  isCollapsed?: boolean;
  /** Side the popover opens to when `isCollapsed` is true. Default `"right"` (PQ1). */
  collapsedPopoverSide?: CollapsedPopoverSide;
  /** Trigger ARIA label. Default `"Switch account context"` (L12). */
  "aria-label"?: string;
  /** Value applied to active item's `aria-current`. Default `"true"` (L14). */
  ariaCurrent?: AccountSwitcherAriaCurrent;

  /**
   * Controlled-open state (L13). When provided, makes the popover controlled.
   * Pair with `onOpenChange` to receive state-change events. Switching between
   * controlled and uncontrolled (e.g., `open={undefined}` → `open={false}`)
   * fires a dev-mode warning — pick one mode at mount time.
   */
  open?: boolean;
  /** Initial open state for uncontrolled mode (L13). Default `false`. */
  defaultOpen?: boolean;
  /**
   * Fires when popover open state changes (L13). F-cross-13 typeof-guarded
   * internally — receives only `boolean`, never `unknown`.
   */
  onOpenChange?: (next: boolean) => void;

  /** Pass-through to trigger element (L1 surface contract). */
  className?: string;
}
src/registry/components/navigation/account-switcher/hooks/use-controllable-state.ts
import { useCallback, useEffect, useRef, useState } from "react";

interface UseControllableStateOpts<T> {
  /** Controlled value. When provided, state is fully controlled by parent. */
  value?: T;
  /** Initial value for uncontrolled mode. */
  defaultValue: T;
  /** Fires on every state change (both modes). */
  onChange?: (next: T) => void;
  /** Component name used in dev warnings. */
  componentName: string;
  /** Prop name used in dev warnings (e.g., `"open"`). */
  valuePropName: string;
}

/**
 * Controlled+uncontrolled state-machine helper.
 *
 * Plan §5.1 + re-validation Finding 1 (⚠️ HIGH):
 *   - Locks the controlled/uncontrolled mode based on the FIRST render's
 *     `value` and dev-warns when consumers flip modes mid-life. Switching
 *     between `value={undefined}` and `value={someBoolean}` is the classic
 *     React anti-pattern; we don't silently swap modes.
 *   - Dev-warns when controlled mode is used without an `onChange` handler
 *     (popover would appear frozen).
 *
 * Both warns are gated on `process.env.NODE_ENV !== "production"` so they
 * tree-shake out of prod bundles.
 *
 * Internal helper — not exported from the procomp's public API.
 */
export function useControllableState<T>({
  value,
  defaultValue,
  onChange,
  componentName,
  valuePropName,
}: UseControllableStateOpts<T>): readonly [T, (next: T) => void] {
  const [internal, setInternal] = useState<T>(defaultValue);
  const isControlled = value !== undefined;
  const onChangeRef = useRef(onChange);
  useEffect(() => {
    onChangeRef.current = onChange;
  });

  const wasControlledRef = useRef(isControlled);
  useEffect(() => {
    if (process.env.NODE_ENV === "production") return;
    if (wasControlledRef.current !== isControlled) {
      console.warn(
        `[${componentName}] \`${valuePropName}\` switched from ${
          wasControlledRef.current ? "controlled" : "uncontrolled"
        } to ${
          isControlled ? "controlled" : "uncontrolled"
        } mode. Components should not switch modes mid-life; pick one at mount.`,
      );
      wasControlledRef.current = isControlled;
    }
  }, [isControlled, componentName, valuePropName]);

  useEffect(() => {
    if (process.env.NODE_ENV === "production") return;
    if (isControlled && !onChangeRef.current) {
      const capitalized = valuePropName.charAt(0).toUpperCase() + valuePropName.slice(1);
      console.warn(
        `[${componentName}] \`${valuePropName}\` is controlled but no onChange handler was provided. ` +
          `State will appear frozen. Pass \`on${capitalized}Change\` ` +
          `(or use the uncontrolled variant by passing \`default${capitalized}\` instead).`,
      );
    }
  }, [isControlled, componentName, valuePropName]);

  const current = isControlled ? (value as T) : internal;

  const set = useCallback(
    (next: T) => {
      if (!isControlled) setInternal(next);
      onChangeRef.current?.(next);
    },
    [isControlled],
  );

  return [current, set] as const;
}
src/registry/components/navigation/account-switcher/lib/enforce-unique-keys.ts
import type { SwitcherItem } from "../types";

/**
 * Strip duplicate-key entries and dev-warn about the violation.
 *
 * Per L3 + Q2 — matches React's own key-uniqueness warning semantics:
 *   - Production: silent strip, last-write-wins-by-position (preserves first
 *     occurrence by source order)
 *   - Development: console.warn listing each duplicate key
 *
 * Callers should wrap in `useMemo([items])` so the dedup pass and the
 * (potential) warn fire once per items-reference change, not per render.
 */
export function enforceUniqueKeys(
  items: ReadonlyArray<SwitcherItem>,
): ReadonlyArray<SwitcherItem> {
  const seen = new Set<string>();
  const deduped: SwitcherItem[] = [];
  const duplicateKeys: string[] = [];
  for (const item of items) {
    if (seen.has(item.key)) {
      duplicateKeys.push(item.key);
      continue;
    }
    seen.add(item.key);
    deduped.push(item);
  }
  if (duplicateKeys.length > 0 && process.env.NODE_ENV !== "production") {
    console.warn(
      `[account-switcher] Duplicate item keys stripped: ${duplicateKeys.join(", ")}. ` +
        `Each item must have a unique \`key\`. First occurrence preserved by source order.`,
    );
  }
  return deduped;
}
src/registry/components/navigation/account-switcher/lib/resolve-active-item.ts
import type { SwitcherItem } from "../types";

/**
 * Result of the active-item resolution pipeline (plan §6).
 *
 * Discriminated union so the renderer can react differently per branch —
 * e.g., visually mark `kind: "fallback"` if the design later wants to, or
 * disable the trigger in the `kind: "empty"` case (Q1 default).
 */
export type ResolvedActive =
  | { kind: "resolved"; item: SwitcherItem }
  | { kind: "fallback"; item: SwitcherItem }
  | { kind: "first"; item: SwitcherItem }
  | { kind: "empty" };

/**
 * Resolve the item to show in the trigger.
 *
 * Priority (L4 + Q1):
 *   1. items.find((i) => i.key === activeKey)   → kind: "resolved"
 *   2. fallbackActiveItem                       → kind: "fallback"
 *   3. items[0]                                 → kind: "first"
 *   4. (none)                                   → kind: "empty"
 */
export function resolveActiveItem(
  items: ReadonlyArray<SwitcherItem>,
  activeKey: string | null,
  fallback: SwitcherItem | undefined,
): ResolvedActive {
  if (activeKey !== null) {
    const match = items.find((i) => i.key === activeKey);
    if (match) return { kind: "resolved", item: match };
  }
  if (fallback) return { kind: "fallback", item: fallback };
  if (items.length > 0) return { kind: "first", item: items[0]! };
  return { kind: "empty" };
}
src/registry/components/navigation/account-switcher/parts/empty-placeholder.tsx
import { cn } from "@/lib/utils";

interface EmptyPlaceholderProps {
  ariaLabel: string;
  isCollapsed: boolean;
  className?: string;
}

/**
 * Disabled trigger rendered when `items` is empty AND no `fallbackActiveItem`
 * is provided (Q1 default: disabled with placeholder text). Defensive
 * against the source's empty-array crash (I-2).
 */
export function EmptyPlaceholder({ ariaLabel, isCollapsed, className }: EmptyPlaceholderProps) {
  if (isCollapsed) {
    return (
      <button
        type="button"
        disabled
        aria-label={ariaLabel}
        className={cn(
          "inline-flex h-10 w-10 cursor-not-allowed items-center justify-center rounded-md border border-dashed border-border bg-card text-muted-foreground opacity-60",
          className,
        )}
      >
        <span className="text-xs">—</span>
      </button>
    );
  }
  return (
    <button
      type="button"
      disabled
      aria-label={ariaLabel}
      className={cn(
        "inline-flex w-full cursor-not-allowed items-center justify-between rounded-md border border-dashed border-border bg-card px-3 py-2 text-sm text-muted-foreground opacity-60",
        className,
      )}
    >
      <span>No items available</span>
    </button>
  );
}
src/registry/components/navigation/account-switcher/parts/render-icon.tsx
import { createElement, isValidElement, type ComponentType, type ReactNode } from "react";

type IconLike = ReactNode | ComponentType<{ className?: string }>;

/**
 * Render an icon prop that accepts either an already-rendered ReactNode
 * (JSX) OR a ComponentType (lucide-react icon, custom component, forwardRef
 * object). Handles all three shapes:
 *
 *   1. JSX element  → return as-is
 *   2. function     → call with { className }
 *   3. forwardRef / memo / exotic component (object with $$typeof) →
 *      use createElement so lucide-react v0.475+ forwardRef icons work
 *      (mirrors the app-sidebar Icon helper fix from 52e5f33).
 *   4. primitive (string/number) → wrap as-is
 *   5. null / undefined → null
 */
export function renderIcon(icon: IconLike | undefined, className?: string): ReactNode {
  if (icon === null || icon === undefined) return null;

  // Already a rendered element
  if (isValidElement(icon)) return icon;

  // Function component
  if (typeof icon === "function") {
    return createElement(icon as ComponentType<{ className?: string }>, { className });
  }

  // forwardRef / memo / exotic (object with $$typeof) — lucide-react v0.475+
  if (typeof icon === "object" && icon !== null && "$$typeof" in icon) {
    return createElement(icon as unknown as ComponentType<{ className?: string }>, { className });
  }

  // Primitive (string / number) — render directly
  return icon as ReactNode;
}
src/registry/components/navigation/account-switcher/parts/switcher-item-row.tsx
import type { AriaAttributes, ReactNode } from "react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
import type { AccountSwitcherAriaCurrent, SwitcherItem } from "../types";
import { renderIcon } from "./render-icon";

interface SwitcherItemRowProps {
  item: SwitcherItem;
  isActive: boolean;
  ariaCurrent: AccountSwitcherAriaCurrent;
  onSelect: () => void;
}

/**
 * Single row inside the popover listbox. Renders icon (if present) + label
 * + Check trail (when active). Active rows are no-op on click (L6) —
 * actual handler in the main component already routes around them.
 *
 * `aria-current` resolution per L14 + PQ3 (data-active for CSS hooks
 * regardless of aria value).
 */
export function SwitcherItemRow({
  item,
  isActive,
  ariaCurrent,
  onSelect,
}: SwitcherItemRowProps) {
  return (
    <li role="presentation" className="contents">
      <button
        type="button"
        role="option"
        aria-selected={isActive || undefined}
        aria-current={resolveAriaCurrent(isActive, ariaCurrent)}
        data-active={isActive || undefined}
        onClick={onSelect}
        className={cn(
          "group flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
          "hover:bg-accent hover:text-accent-foreground",
          "focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none",
          isActive && "bg-accent/60 text-accent-foreground",
        )}
      >
        {item.icon ? (
          <span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground group-hover:text-foreground">
            {renderIcon(item.icon, "h-4 w-4") as ReactNode}
          </span>
        ) : null}
        <span className="min-w-0 flex-1 truncate">{item.label}</span>
        {isActive ? (
          <Check className="ml-auto h-4 w-4 shrink-0 text-foreground" aria-hidden="true" />
        ) : null}
      </button>
    </li>
  );
}

function resolveAriaCurrent(
  isActive: boolean,
  override: AccountSwitcherAriaCurrent,
): AriaAttributes["aria-current"] | undefined {
  if (!isActive) return undefined;
  if (override === false) return undefined;
  return override;
}
src/registry/components/navigation/account-switcher/parts/switcher-trigger.tsx
import { forwardRef, type ReactNode } from "react";
import { ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import type { SwitcherItem } from "../types";
import { renderIcon } from "./render-icon";

interface SwitcherTriggerProps {
  /** Resolved active item (may be a fallback / first / etc.). */
  activeItem: SwitcherItem | null;
  /** Trigger ARIA label. Composed with active label per PQ4. */
  ariaLabel: string;
  /** ID of the listbox the trigger controls (jsx-a11y combobox requirement). */
  ariaControls: string;
  /** When true, render icon-only 40x40 square; no label, no chevron (L10). */
  isCollapsed: boolean;
  /** Whether the popover is currently open (drives aria-expanded). */
  open: boolean;
  /** Whether the trigger is disabled (e.g., empty-items state). */
  disabled?: boolean;
  /** Pass-through className from props. */
  className?: string;
  /** Click handler (Popover.Trigger native). */
  onClick?: () => void;
}

/**
 * PQ4 — composed aria-label includes the active item label when one resolves
 * ("Switch account context, current: Acme Corp").
 */
export function composeSwitcherTriggerAriaLabel(
  ariaLabel: string,
  activeItem: SwitcherItem | null,
): string {
  return activeItem ? `${ariaLabel}, current: ${activeItem.label}` : ariaLabel;
}

/**
 * Trigger button classes — two branches per `isCollapsed`:
 *   - expanded: full-width with icon + label + chevron, width drives popover
 *     width via --radix-popover-trigger-width
 *   - collapsed: 40x40 icon-only square; popover opens to the side
 *
 * v0.1.1 (F-cross-13 path-b): exported as a class builder because the
 * assembly no longer mounts a <button> via `<PopoverTrigger asChild>` —
 * Base UI's PopoverTrigger has no `asChild` (and the CLI's render-rewrite
 * breaks mixed consumers). The primitive trigger IS the button now:
 * account-switcher.tsx renders
 * `<PopoverTrigger className={switcherTriggerClassName(…)}>` with
 * `<SwitcherTriggerContent>` inside, passing only native DOM props across
 * the shadcn-primitive boundary.
 */
export function switcherTriggerClassName({
  isCollapsed,
  className,
}: {
  isCollapsed: boolean;
  className?: string;
}): string {
  if (isCollapsed) {
    return cn(
      "inline-flex h-10 w-10 items-center justify-center rounded-md border border-border bg-card text-foreground transition-colors",
      "hover:bg-accent hover:text-accent-foreground",
      "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
      "disabled:cursor-not-allowed disabled:opacity-50",
      className,
    );
  }
  return cn(
    "inline-flex w-full items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground transition-colors",
    "hover:bg-accent hover:text-accent-foreground",
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
    "disabled:cursor-not-allowed disabled:opacity-50",
    className,
  );
}

/**
 * Inner trigger content (icon / initial / label / chevron), backend-neutral —
 * rendered inside whichever button element hosts the trigger.
 */
export function SwitcherTriggerContent({
  activeItem,
  isCollapsed,
}: {
  activeItem: SwitcherItem | null;
  isCollapsed: boolean;
}) {
  if (isCollapsed) {
    return activeItem?.icon ? (
      <>{renderIcon(activeItem.icon, "h-4 w-4") as ReactNode}</>
    ) : (
      <span className="text-xs font-medium uppercase">
        {activeItem?.label?.charAt(0) ?? "?"}
      </span>
    );
  }
  return (
    <>
      {activeItem?.icon ? (
        <span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
          {renderIcon(activeItem.icon, "h-4 w-4") as ReactNode}
        </span>
      ) : null}
      <span className="min-w-0 flex-1 truncate text-left">
        {activeItem?.label ?? "—"}
      </span>
      <ChevronsUpDown
        className="ml-auto h-4 w-4 shrink-0 text-muted-foreground"
        aria-hidden="true"
      />
    </>
  );
}

/**
 * Standalone trigger button — v0.1.0 shape, byte-identical rendering, kept
 * for API stability (the part file ships; direct importers keep working).
 * Since v0.1.1 the assembly no longer routes it through
 * `<PopoverTrigger asChild>` (F-cross-13) — see switcherTriggerClassName
 * above for the primitive-trigger path.
 */
export const SwitcherTrigger = forwardRef<HTMLButtonElement, SwitcherTriggerProps>(
  function SwitcherTrigger(
    { activeItem, ariaLabel, ariaControls, isCollapsed, open, disabled, className, onClick, ...rest },
    ref,
  ) {
    return (
      <button
        ref={ref}
        type="button"
        role="combobox"
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-controls={ariaControls}
        aria-label={composeSwitcherTriggerAriaLabel(ariaLabel, activeItem)}
        disabled={disabled}
        onClick={onClick}
        className={switcherTriggerClassName({ isCollapsed, className })}
        {...rest}
      >
        <SwitcherTriggerContent activeItem={activeItem} isCollapsed={isCollapsed} />
      </button>
    );
  },
);

More from ilinxa

Browse all components in this library →