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>
);
}