import * as Select from "@radix-ui/react-select"; import { IconLock, IconWorld, IconTrash, IconCheck, IconChevronDown, IconCopy, IconLoader2, IconSearch, IconSearchOff, IconShare3, IconUsersGroup, } from "@tabler/icons-react"; import { type ComponentPropsWithoutRef, useEffect, useId, useRef, useState, type ReactNode, } from "react"; import type { KeyboardEvent as ReactKeyboardEvent, UIEvent as ReactUIEvent, } from "react"; import { writeClipboardText } from "../clipboard.js"; import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger, } from "../components/ui/popover.js"; import { cn } from "../utils.js"; import { useShareButtonController, type ShareButtonController, type ShareButtonOrgMember, type ShareButtonOrgMemberSearch, type ShareButtonShare, type ShareButtonRole, type ShareButtonVisibility, } from "./useShareButtonController.js"; export interface ShareButtonProps { resourceType: string; resourceId: string; resourceTitle?: string; /** @deprecated No longer affects rendering — trigger always says * "Share". Kept for callsite compatibility. */ variant?: "compact" | "label"; /** Optional trigger style. Defaults to a text-only "Share" label. * "label-icon" opts into an icon plus label; "icon" is icon-only. */ trigger?: "label" | "icon" | "label-icon"; /** @deprecated No longer affects rendering — kept for callsite compatibility. */ hideTriggerIcon?: boolean; /** Optional className applied to the trigger button. */ triggerClassName?: string; /** Notified when the share popover opens or closes. Hosts that render the * button next to an iframe use this to disable the iframe's pointer events * while the popover is open, so popover hover/clicks aren't swallowed. */ onOpenChange?: (open: boolean) => void; /** Open the popover on first render. Useful after an upgrade/create flow that * lands the user directly in the shareable resource. */ defaultOpen?: boolean; /** Optional public/share URL shown as a copyable link in the popover. * This is treated as the primary "Copy link" target — same convention * as Google Docs' Share dialog, which copies the editor URL. */ shareUrl?: string; /** Optional label for the primary copyable link section. */ shareUrlLabel?: string; /** Optional helper text for the primary copyable link section. */ shareUrlDescription?: ReactNode; /** Where to render share links in the popover. Defaults to the bottom, * matching the historical Google-Docs-style share dialog. */ shareUrlPlacement?: "top" | "bottom"; /** Whether to render copyable share URL fields. Defaults to true. */ showShareLinks?: boolean; /** @deprecated The Done action was removed; share popovers dismiss directly. */ showDoneButton?: boolean; /** Optional placeholder shown in the share-URL slot when `shareUrl` is * undefined. Use this to explain *why* there's no link yet (e.g. "Publish * this form to get a public response link") instead of leaving the slot * empty. */ shareUrlPlaceholder?: ReactNode; /** Optional secondary copyable link (e.g. a presentation / read-only * surface for the same resource). Anyone with at least viewer access * can open it — access is enforced on the resource itself, not the * URL shape, so we never gate this behind visibility. */ secondaryShareUrl?: string; /** Optional label for the secondary copyable link. */ secondaryShareUrlLabel?: string; /** Optional helper text for the secondary copyable link. */ secondaryShareUrlDescription?: ReactNode; /** @deprecated No longer enforced — access is checked on the resource, * not the URL shape, mirroring Google Slides. Kept for callsite * compatibility; the prop is now a no-op. */ shareUrlRequiresPublic?: boolean; /** @deprecated See `shareUrlRequiresPublic`. No longer rendered. */ shareUrlUnavailableDescription?: ReactNode; /** Optional template-specific copy for the visibility picker. */ visibilityCopy?: Partial< Record >; /** Optional label for the explicit per-person access list. */ peopleAccessLabel?: ReactNode; /** Optional label for the coarse visibility control. */ generalAccessLabel?: ReactNode; /** Optional note rendered between general access and the copyable link. */ accessNote?: ReactNode; /** Optional host-rendered footer for compact app-specific share actions. */ shareFooterContent?: ReactNode; /** Optional Notion-style organization access control. When present, the * share panel exposes a "Hide in search" switch under Advanced for org * visibility. */ hideInSearchControl?: { checked: boolean; pending?: boolean; label?: string; description?: ReactNode; onCheckedChange: (checked: boolean) => void | Promise; }; /** Optional extra tabs rendered beside the default sharing/access panel. */ shareTabs?: { shareLabel?: ReactNode; defaultValue?: string; tabs: Array<{ value: string; label: ReactNode; content: ReactNode; disabled?: boolean; }>; onValueChange?: (value: string) => void; }; /** Optional className for the popover content, useful for wider custom tabs. */ popoverClassName?: string; } type Visibility = ShareButtonVisibility; type Role = ShareButtonRole; type HideInSearchControl = NonNullable; type OrgMember = ShareButtonOrgMember; type OrgMemberSearch = ShareButtonOrgMemberSearch; type Share = ShareButtonShare; // Match shadcn's e.preventDefault()} onInteractOutside={handleSharePopoverInteractOutside} > ); } function SharePanel( props: ShareButtonProps & { controller: ShareButtonController; }, ) { const { resourceTitle, controller } = props; const { inviteEmail, setInviteEmail: onInviteEmailChange, activeShareTab, handleShareTabChange, data, policy, visibility, canManage, role, setRole, notifyPeople, setNotifyPeople, shareError, setShareError, suggestionsOpen, setSuggestionsOpen, inFlight, memberSearch, memberSuggestions, knownMembers, shares, handleVisibility, handleHideInSearch, handleAdd, handleChangeRole, handleRemove, } = controller; const hasInviteEmail = inviteEmail.trim().length > 0; const isLoading = data === undefined; const meta = visibilityMeta(visibility, props.visibilityCopy); const peopleAccessLabel = props.peopleAccessLabel ?? "People with access"; const generalAccessLabel = props.generalAccessLabel ?? "General access"; const shareLinks = ( <> {props.shareUrl ? ( ) : props.shareUrlPlaceholder ? (
{props.shareUrlLabel ? (
{props.shareUrlLabel}
) : null} {props.shareUrlPlaceholder}
) : null} {props.secondaryShareUrl ? ( ) : null} ); const showShareLinks = (props.showShareLinks ?? true) && (Boolean(props.shareUrl) || Boolean(props.shareUrlPlaceholder) || Boolean(props.secondaryShareUrl)); const shareUrlPlacement = props.shareUrlPlacement ?? "bottom"; const extraTabs = props.shareTabs?.tabs ?? []; const hasTabs = extraTabs.length > 0; const shareTabLabel = props.shareTabs?.shareLabel ?? "Share link"; const titleText = resourceTitle ? `Share "${resourceTitle}"` : `Share ${props.resourceType}`; const sharePanel = isLoading ? (
{!hasTabs ? (
{titleText}
) : null}
{peopleAccessLabel}
{generalAccessLabel}
) : (
{!hasTabs ? (
{titleText}
) : null} {showShareLinks && shareUrlPlacement === "top" ? shareLinks : null} {canManage ? (
{ onInviteEmailChange(next); if (shareError) setShareError(null); }} onSelectMember={(member) => { onInviteEmailChange(member.email); setSuggestionsOpen(false); if (shareError) setShareError(null); }} onSubmit={handleAdd} placeholder={ policy.requireOrgMemberForUserShares ? "Add people from your organization" : "Add people by email" } suggestions={memberSuggestions} search={memberSearch} />
{shareError ? (
{shareError}
) : null} {hasInviteEmail ? ( ) : null}
) : null}
{peopleAccessLabel}
    {data?.ownerEmail ? (
  • {displayName(data.ownerEmail, knownMembers)} Owner
  • ) : null} {shares.map((s) => (
  • {principalLabel(s, knownMembers)} {canManage ? ( handleChangeRole(s, r)} disabled={inFlight.has(keyOf(s))} plain /> ) : ( {cap(s.role)} )} {canManage ? ( ) : null}
  • ))} {!shares.length && !data?.ownerEmail ? (
  • No one has access yet.
  • ) : null}
{generalAccessLabel}
{meta.description} {visibility === "org" && props.hideInSearchControl ? ( ) : null}
{shareError && !canManage ? (
{shareError}
) : null} {props.accessNote ? (
{props.accessNote}
) : null} {showShareLinks && shareUrlPlacement === "bottom" ? shareLinks : null} {props.shareFooterContent}
); if (!hasTabs) return sharePanel; const tabs = [ { value: "share", label: shareTabLabel, content: sharePanel, disabled: false, }, ...extraTabs, ]; const activeTab = tabs.some((tab) => tab.value === activeShareTab) ? activeShareTab : "share"; return (
{tabs.map((tab) => { const active = tab.value === activeTab; return ( ); })}
{tabs.find((tab) => tab.value === activeTab)?.content}
); } function AdvancedAccessPopover({ control, canManage, onToggle, }: { control: HideInSearchControl; canManage: boolean; onToggle: () => void; }) { return ( event.preventDefault()} className={cn( SHARE_NESTED_OVERLAY_Z, "w-72 p-3 shadow-lg", SHARE_POPOVER_SURFACE, )} >
Advanced access
Control how organization access appears in search.
); } interface MemberAutocompleteProps { value: string; open: boolean; onOpenChange: (open: boolean) => void; onValueChange: (value: string) => void; onSelectMember: (member: OrgMember) => void; onSubmit: () => void; placeholder: string; suggestions: OrgMember[]; search: OrgMemberSearch; } function MemberAutocomplete({ value, open, onOpenChange, onValueChange, onSelectMember, onSubmit, placeholder, suggestions, search, }: MemberAutocompleteProps) { const rawListboxId = useId(); const listboxId = rawListboxId.replace(/:/g, ""); const inputRef = useRef(null); const [activeIndex, setActiveIndex] = useState(-1); const activeMember = activeIndex >= 0 && activeIndex < suggestions.length ? suggestions[activeIndex] : null; useEffect(() => { setActiveIndex(-1); }, [value]); useEffect(() => { if (activeIndex >= suggestions.length) { setActiveIndex(suggestions.length > 0 ? suggestions.length - 1 : -1); } }, [activeIndex, suggestions.length]); useEffect(() => { if (activeIndex < 0) return; document .getElementById(optionId(listboxId, activeIndex)) ?.scrollIntoView({ block: "nearest" }); }, [activeIndex, listboxId]); const chooseMember = (member: OrgMember) => { onSelectMember(member); onOpenChange(false); inputRef.current?.focus(); }; const handleKeyDown = (event: ReactKeyboardEvent) => { if (event.key === "ArrowDown") { event.preventDefault(); onOpenChange(true); if (suggestions.length === 0) return; setActiveIndex((prev) => { if (prev >= suggestions.length - 1) { if (search.hasMore && !search.isLoadingMore) search.loadMore(); return suggestions.length - 1; } return prev + 1; }); return; } if (event.key === "ArrowUp") { event.preventDefault(); onOpenChange(true); if (suggestions.length === 0) return; setActiveIndex((prev) => (prev <= 0 ? suggestions.length - 1 : prev - 1)); return; } if (event.key === "Enter") { if (open && activeMember) { event.preventDefault(); chooseMember(activeMember); return; } if (value.trim()) { event.preventDefault(); onSubmit(); } return; } if (event.key === "Escape" && open) { event.preventDefault(); event.stopPropagation(); onOpenChange(false); setActiveIndex(-1); } }; const handleScroll = (event: ReactUIEvent) => { const target = event.currentTarget; if ( search.hasMore && !search.isLoadingMore && target.scrollTop + target.clientHeight >= target.scrollHeight - 24 ) { search.loadMore(); } }; return (
= 0 ? optionId(listboxId, activeIndex) : undefined } placeholder={placeholder} value={value} onChange={(event) => { onValueChange(event.target.value); onOpenChange(true); }} onFocus={() => onOpenChange(true)} onBlur={() => { setTimeout(() => { if (document.activeElement !== inputRef.current) { onOpenChange(false); } }, 0); }} onKeyDown={handleKeyDown} autoComplete="off" className="h-9 w-full min-w-0 rounded-md border border-input bg-card ps-8 pe-8 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background" /> {search.isLoading ? ( ) : null}
event.preventDefault()} className={cn( SHARE_NESTED_OVERLAY_Z, "w-[var(--radix-popper-anchor-width)] min-w-[18rem] rounded-md p-1 shadow-lg", SHARE_POPOVER_SURFACE, )} >
{suggestions.map((member, index) => { const active = index === activeIndex; return (
event.preventDefault()} onMouseEnter={() => setActiveIndex(index)} onClick={() => chooseMember(member)} className={cn( "flex cursor-pointer select-none flex-col rounded-sm px-3 py-2 text-sm outline-none", active ? "bg-accent text-accent-foreground" : "text-foreground hover:bg-accent hover:text-accent-foreground", )} > {member.name?.trim() || member.email} {member.name?.trim() ? ( {member.email} ) : null}
); })} {search.isLoading && suggestions.length === 0 ? (
Searching...
) : null} {search.error ? (
Could not load people.
) : null} {!search.isLoading && !search.error && suggestions.length === 0 ? (
{value.trim() ? "No matches." : "No people found."}
) : null} {search.isLoadingMore ? (
Loading...
) : null} {search.hasMore && !search.isLoadingMore ? ( ) : null}
); } function optionId(baseId: string, index: number): string { return `${baseId}-option-${index}`; } function CopyLinkField({ value, label = "Share link", description, }: { value: string; label?: string; description?: ReactNode; }) { const [copied, setCopied] = useState(false); const resetRef = useRef>(undefined); useEffect(() => { return () => { if (resetRef.current) clearTimeout(resetRef.current); }; }, []); const handleCopy = async () => { if (await writeClipboardText(value)) { setCopied(true); if (resetRef.current) clearTimeout(resetRef.current); resetRef.current = setTimeout(() => setCopied(false), 1400); } else { setCopied(false); } }; return (
{label}
{description ? (
{description}
) : null}
event.currentTarget.select()} />
); } // --------------------------------------------------------------------------- // Radix Select wrappers styled like shadcn Select (no native