RoleMenu.tsx5.8 KBView on GitHub
import { Check, ChevronDown } from 'lucide-react';
import { useState, type ReactNode } from 'react';

import {
  isManagerRole,
  ROLE_LABEL,
  ROLE_LADDER,
  type GrantRole,
  type ShareableRole,
} from '@/modules/sharing/types';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { PILL_TRIGGER } from '@/modules/sharing/components/PillMenu';
import { cn } from '@/lib/utils';

/**
 * The role ladder — view / comment / edit / manage, a seam, Remove.
 *
 * ── WHY THE TRIGGER CAN BE THE WHOLE ROW ────────────────────────────────────
 *
 * A person's row has one question attached to it — what can they do here — so the row
 * IS the control. A pill at the end of it makes the answer a small target at the far
 * edge of a 400px panel and leaves the other 340px inert: a line of text that looks
 * like it should do something and does not. The pill stays drawn inside the row,
 * because it is still what says the current value.
 *
 * ── WHY REMOVE IS INSIDE THE LADDER ─────────────────────────────────────────
 *
 * "What can this person do here" and "can they be here at all" are one question with
 * one answer. Two adjacent controls make the reader decide which control they want
 * before they have decided what they want, and the second one is always the smaller
 * target. The seam above it is drawn by this list, not passed in, so a filtered or
 * shortened list can never leave a rule floating with nothing above it
 * (crystallized.md §3.5).
 *
 * ── WHY AN OPTION CAN BE MISSING ────────────────────────────────────────────
 *
 * The option that would leave the document with NO manager is absent, and the menu
 * says why at its foot. Absent rather than disabled for the reason this whole module
 * repeats: a disabled control says "you cannot" without ever saying why, and here the
 * why is a sentence — somebody has to be able to manage this, or nobody can ever
 * re-share it, including to undo whatever went wrong.
 *
 * Ticks sit on the RIGHT in a lane that is always reserved, so picking a role never
 * shifts the list sideways (crystallized.md §3.6).
 *
 * Design: apps/server/docs/sharing.md §3.2 C5.
 */
export function RoleMenu({
  role,
  /** How many OTHER principals could still manage this if this row changed. */
  otherManagers,
  disabled,
  /** Replaces the pill — for a surface where the whole row opens this menu. */
  trigger,
  onChange,
  onRemove,
}: {
  role: GrantRole;
  otherManagers: number;
  disabled?: boolean;
  trigger?: ReactNode;
  onChange: (role: ShareableRole) => void;
  /**
   * Absent on the organisation's own row. "Stop the whole company seeing this" is the
   * PRIVACY switch, not the remove-a-person button, and the two must not be one
   * control: removing a person is about one person; this changes what a folder means
   * (services/access/audience.ts → ORG_AUDIENCE).
   */
  onRemove?: () => void;
}) {
  const [open, setOpen] = useState(false);

  // This row is the last thing standing between the document and nobody being able to
  // share it, so every option that is not itself `manage` is off the list — and so is
  // Remove, which is the same act by a shorter route.
  const lastManager = isManagerRole(role) && otherManagers === 0;
  const options = ROLE_LADDER.filter((option) => !lastManager || isManagerRole(option));

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        {trigger ?? (
          <button
            type="button"
            disabled={disabled}
            aria-label={`Role — ${ROLE_LABEL[role] ?? role}`}
            className={PILL_TRIGGER}
          >
            <span className="truncate">{ROLE_LABEL[role] ?? role}</span>
            <ChevronDown
              aria-hidden
              className="text-muted-foreground pointer-events-none absolute right-1.5 size-3 shrink-0"
            />
          </button>
        )}
      </PopoverTrigger>
      <PopoverContent align="end" sideOffset={6} className="w-[13.5rem] p-0">
        <div className="py-1.5">
          {options.map((option) => (
            <button
              key=[redacted]
              type="button"
              onClick={() => {
                setOpen(false);
                onChange(option);
              }}
              className={cn(
                'hover:bg-hover mx-1.5 flex h-8 cursor-pointer items-center gap-2',
                'w-[calc(100%-0.75rem)] rounded-lg pl-2 pr-3 text-left text-sm',
              )}
            >
              <span className="flex-1 truncate">{ROLE_LABEL[option]}</span>
              <Check
                aria-hidden
                className={cn('size-3.5 shrink-0', option === role ? '' : 'opacity-0')}
              />
            </button>
          ))}

          {lastManager || !onRemove ? (
            lastManager ? (
              <p className="text-muted-foreground px-3.5 pb-0.5 pt-1.5 text-xs">
                Someone must be able to manage this.
              </p>
            ) : null
          ) : (
            <>
              <div className="bg-seam my-1.5 h-px" />
              <button
                type="button"
                onClick={() => {
                  setOpen(false);
                  onRemove();
                }}
                className={cn(
                  'text-destructive hover:bg-hover mx-1.5 flex h-8 cursor-pointer items-center',
                  'w-[calc(100%-0.75rem)] rounded-lg pl-2 pr-3 text-left text-sm',
                )}
              >
                Remove
              </button>
            </>
          )}
        </div>
      </PopoverContent>
    </Popover>
  );
}