Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | 6x 75x 75x 375x 135x 135x 6x | import {
Archive,
ArchiveRestore,
FileEdit,
MoreHorizontal,
Trash2,
Upload,
} from "lucide-react";
import type { ComponentType } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Separator } from "../ui/separator";
import { cn } from "../../lib/utils";
import {
type ProjectAction,
type ProjectActionId,
projectActions,
type ProjectActionInput,
} from "./project-actions";
/**
* The `[ ⋯ ]` menu from `project-detail-details.html`, drawn with the **full
* action set at every status**.
*
* The mockup's own note is the specification: "Every action is listed at every
* status — an archived project can always come back." An item that does not
* apply right now is printed with its reason ("already live", "not
* archived"), not hidden — hiding it is what made an archived project a dead
* end in the first place.
*
* Composed from `Popover` rather than `DropdownMenu` because this panel has to
* hang from the RIGHT edge of a right-aligned header (`align="end"`), carry a
* separator before the danger item, and print a closing note — none of which
* `DropdownMenu`'s string-label API can express. `Popover` brings the focus
* handling, Escape and outside-press dismissal with it.
*/
const ACTION_ICONS: Record<ProjectActionId, ComponentType<{ className?: string }>> =
{
publish: Upload,
"move-to-draft": FileEdit,
archive: Archive,
unarchive: ArchiveRestore,
delete: Trash2,
};
export interface ProjectActionsMenuProps extends ProjectActionInput {
/** Invoked with the chosen action. Confirmation is the caller's job. */
onAction: (id: ProjectActionId) => void;
/** Every item is inert while a mutation is in flight. */
isBusy?: boolean;
}
export function ProjectActionsMenu({
onAction,
isBusy,
...availability
}: ProjectActionsMenuProps) {
const actions = projectActions(availability);
return (
<Popover>
<PopoverTrigger
title="More actions"
className={cn(
"inline-flex size-8 items-center justify-center rounded-md",
"border border-border-default bg-background-elevated text-foreground-default",
"transition-colors hover:border-border-strong hover:bg-background-muted",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
)}
>
<MoreHorizontal aria-hidden="true" className="size-4" />
<span className="sr-only">
More actions — publish, move to draft, archive, unarchive, delete
</span>
</PopoverTrigger>
<PopoverContent
label="Project actions"
align="end"
className="w-64 p-1"
>
<p className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-foreground-muted">
This project
</p>
{actions.map((action) => (
<MenuItem
key={action.id}
action={action}
isBusy={isBusy}
onAction={onAction}
/>
))}
<p className="px-2 pt-1.5 pb-1 text-xs text-foreground-muted">
Every action is listed at every status — an archived project can
always come back.
</p>
</PopoverContent>
</Popover>
);
}
function MenuItem({
action,
isBusy,
onAction,
}: {
action: ProjectAction;
isBusy?: boolean;
onAction: (id: ProjectActionId) => void;
}) {
const Icon = ACTION_ICONS[action.id];
return (
<>
{action.danger && <Separator className="my-1" />}
<button
type="button"
disabled={!action.enabled || isBusy}
onClick={() => onAction(action.id)}
className={cn(
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm",
"disabled:pointer-events-none disabled:opacity-50",
action.danger
? "text-error hover:bg-error-light"
: "text-foreground-default hover:bg-foreground-default/7",
)}
>
<Icon className="size-4 shrink-0" />
<span className="grow">{action.label}</span>
{action.reason && (
<span className="shrink-0 text-xs font-semibold uppercase tracking-wider text-foreground-muted">
{action.reason}
</span>
)}
</button>
</>
);
}
|