/* ---------------------------------------------------------------------------- RoleSelect - one role per person, from the server's role catalog. Roles are passed in (never a client copy), each with its own description. Picking a role that grants more power (Owner, Admin) asks first. Legacy custom grants show as "Custom (set via API)" and can be replaced by a role. Variants: - "field" (default): a bordered select for rows and forms. - "cell": a borderless select for dense grids (the access matrix). - "list": every role with its description as a radio list, for sheets and the invite dialog. - "text": the role as quiet text, for read-only views. -------------------------------------------------------------------------- */ import { useId, useState } from "react"; import { CheckIcon, ChevronDownIcon, LoaderCircleIcon, LockIcon } from "lucide-react"; import { RadioGroup as RadioGroupPrimitive, Select as SelectPrimitive } from "radix-ui"; import { MENU_CHECK_CLASS, MENU_SEPARATOR_CLASS, MENU_SURFACE_CLASS } from "./menu-styles"; import { cn } from "../lib/cn"; import { ReasonTooltip } from "./disabled-reason"; import { FormDialog } from "./form-dialog"; import { InlineDisabledReason } from "./select-menu"; import { containedPortal } from "./portal-container"; const SelectContainedPortal = containedPortal(SelectPrimitive.Portal); export interface RoleOption { id: R; /** "Workspace admin". */ label: string; /** One line from the role catalog: what someone with it can do. */ description: string; /** * Grants more power, so picking it asks first (Owner, Admin). Pass a * string to replace the role description in the confirmation. */ escalation?: boolean | string | undefined; /** Why this role can't be picked here. */ disabledReason?: string | undefined; } /** A role id, "custom" for a legacy hand-picked grant, or null for no access. */ export type RoleValue = R | "custom" | null; export type RoleSelectVariant = "field" | "cell" | "list" | "text"; export const CUSTOM_ROLE_LABEL = "Custom (set via API)"; const CUSTOM_ROLE_DESCRIPTION = "Hand-picked permissions from the API. Pick a role to replace them."; const NO_ACCESS_DESCRIPTION = "Can't open this workspace."; const NONE = "__none"; const CUSTOM = "__custom"; export interface RoleSelectProps { roles: readonly RoleOption[]; value: RoleValue; /** * Saves the new role. Return a promise to show the saving state until it * settles; the select keeps showing the new role meanwhile. */ onValueChange?: ((role: R | null) => void | Promise) | undefined; variant?: RoleSelectVariant | undefined; /** "sm" is 32px for rows, "md" is 36px for forms. Field variant only. */ size?: "sm" | "md" | undefined; /** Adds a "No access" option (value null), for the person sheet and the matrix. */ noAccessLabel?: string | undefined; customLabel?: string | undefined; /** The person whose role this is, for confirmations and screen readers. */ subjectName?: string | undefined; disabled?: boolean | undefined; /** Shown in a tooltip on the disabled control, and read by screen readers. */ disabledReason?: string | undefined; /** Saving started elsewhere. */ pending?: boolean | undefined; /** Menu alignment against the trigger. */ align?: "start" | "end" | undefined; "aria-label"?: string | undefined; "aria-labelledby"?: string | undefined; id?: string | undefined; className?: string | undefined; } function toKey(value: RoleValue): string { if (value === null) return NONE; if (value === "custom") return CUSTOM; return value; } function withArticle(label: string): string { const lower = label.toLowerCase(); return `${/^[aeiou]/.test(lower) ? "an" : "a"} ${lower}`; } /** The label a role value shows, for rows, toasts and read-only text. */ export function roleLabel( roles: readonly RoleOption[], value: RoleValue, { noAccessLabel = "No access", customLabel = CUSTOM_ROLE_LABEL, }: { noAccessLabel?: string | undefined; customLabel?: string | undefined } = {}, ): string { if (value === null) return noAccessLabel; if (value === "custom") return customLabel; return roles.find((role) => role.id === value)?.label ?? value; } /** Whether moving to a role needs a confirmation first: only roles marked as escalations do. */ export function needsEscalationConfirm( roles: readonly RoleOption[], from: RoleValue, to: R | null, ): boolean { if (to === null || to === from) return false; return Boolean(roles.find((role) => role.id === to)?.escalation); } // The one menu surface and option row (menu-styles.ts), as in select-menu.tsx: // the list carries the 6px inset, the chosen option a check on the right. const menuContent = cn( MENU_SURFACE_CLASS, "z-50 max-h-(--radix-select-content-available-height) w-[min(19rem,calc(100vw-24px))] min-w-(--radix-select-trigger-width) overflow-hidden p-0", ); const menuItem = "relative flex min-h-8 cursor-pointer flex-col items-start rounded-[10px] py-1.5 pr-9 pl-2.5 text-left outline-hidden select-none data-[disabled]:cursor-not-allowed data-[highlighted]:bg-surface-2 pointer-coarse:min-h-11"; // Field trigger: the Select primitive's look at 32px (rows) or 36px (forms). const fieldTrigger = "border border-border bg-surface px-3 hover:border-border-strong data-[state=open]:border-border-strong"; function MenuItem({ value, label, description, disabled, muted, }: { value: string; label: string; description: string; disabled?: boolean | undefined; muted?: boolean | undefined; }) { return ( {label} {description} ); } /** Pick one role, from the server's catalog. Saves on change. */ export function RoleSelect({ roles, value, onValueChange, variant = "field", size = "sm", noAccessLabel, customLabel = CUSTOM_ROLE_LABEL, subjectName, disabled, disabledReason, pending: pendingProp, align = "end", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, id, className, }: RoleSelectProps) { const [saving, setSaving] = useState | undefined>(undefined); const [confirming, setConfirming] = useState | null>(null); const reasonId = useId(); const shown: RoleValue = saving !== undefined ? saving : value; const pending = pendingProp || saving !== undefined; const isDisabled = disabled || Boolean(disabledReason); const label = roleLabel(roles, shown, { noAccessLabel, customLabel }); const accessibleName = ariaLabel ?? (subjectName ? `Role for ${subjectName}` : "Role"); const commit = async (next: R | null) => { if (!onValueChange) return; const result = onValueChange(next); if (result && typeof (result as Promise).then === "function") { setSaving(next); try { await result; } finally { setSaving(undefined); } } }; const choose = (next: R | null) => { if (next === shown) return; if (needsEscalationConfirm(roles, shown, next)) { setConfirming(roles.find((role) => role.id === next) ?? null); return; } void commit(next); }; // Escalation asks first, in the same dialog anatomy as every other confirm. const confirmation = confirming ? ( { if (!open) setConfirming(null); }} title={`Make ${subjectName ?? "this person"} ${withArticle(confirming.label)}?`} description={ typeof confirming.escalation === "string" ? confirming.escalation : confirming.description } submitLabel={`Make ${confirming.label.toLowerCase()}`} initialFocus="cancel" onSubmit={() => { const next = confirming.id; setConfirming(null); void commit(next); }} /> ) : null; if (variant === "text") { return ( {label} ); } if (variant === "list") { return ( <> choose(key === NONE ? null : (key as R))} disabled={isDisabled || pending} aria-label={ariaLabelledBy ? undefined : accessibleName} aria-labelledby={ariaLabelledBy} aria-describedby={disabledReason ? reasonId : undefined} aria-busy={pending || undefined} className={cn("flex min-w-0 flex-col gap-0.5", className)} > {shown === "custom" ? (
) : null} {noAccessLabel ? ( ) : null} {roles.map((role) => ( ))}
{disabledReason ? (
{disabledReason}
) : null} {confirmation} ); } const cell = variant === "cell"; const triggerShape = cn( "group/role inline-flex w-full min-w-0 items-center justify-between gap-2 rounded-[10px] text-sm transition-colors duration-[120ms]", cell ? "h-8 px-2 hover:bg-surface-2 data-[state=open]:bg-surface-2 pointer-coarse:h-11" : cn(fieldTrigger, size === "md" ? "h-9 pointer-coarse:h-11" : "h-8 pointer-coarse:h-11"), className, ); // Locked: not a select at all. A focusable, aria-disabled control that // explains itself on hover, focus and tap (the shared ReasonTooltip). if (disabledReason) { return ( <> {disabledReason} ); } return ( <> choose(key === NONE ? null : (key as R))} disabled={disabled || pending} > {label} {pending ? ( {shown === "custom" ? ( <> ) : null} {noAccessLabel ? ( ) : null} {roles.map((role) => ( ))} {confirmation} ); } function ListOption({ value, label, description, disabled, saving, }: { value: string; label: string; description: string; disabled?: boolean | undefined; saving?: boolean | undefined; }) { return ( {label} {saving ? ( {description} ); }