'use client' import React from 'react' import { cn } from '../../utils/cn' import { Chevron02DownIcon } from '../icons-v2-generated' import { ActionsMenuDropdown, type ActionsMenuGroup, type ActionsMenuItem } from './actions-menu' import type { ButtonProps, SplitButtonIconAction } from './button' import { Button, SplitButton } from './button' import { Skeleton } from './skeleton' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip' export type PageActionButton = { /** Button label. Omit to render an icon-only button. */ label?: string /** Accessible name. Required for icon-only buttons (when `label` is omitted). */ ariaLabel?: string /** Click handler. Optional when `href` or `submenu` is provided. */ onClick?: () => void icon?: React.ReactNode /** `overlay` is media chrome only — SplitButton has no divider colour for it. */ variant?: Exclude disabled?: boolean /** * For SplitButton actions (when `iconAction` is set): disables only the main * half. Combine with `iconAction.disabled` for icon-only disable. Ignored * for non-SplitButton actions. */ mainDisabled?: boolean loading?: boolean /** Show action only on mobile (below md). Default: visible on all screens. */ showOnlyMobile?: boolean /** * Render the desktop button as icon-only (label hidden, icon centered). The full * label still appears in the mobile "..." dropdown. The desktop icon is forced to * `text-ods-text-primary`; the mobile row keeps the caller-provided icon color. */ iconOnlyOnDesktop?: boolean /** Render as a link (next/link). Mutually exclusive with `submenu`. */ href?: string /** Forwarded to next/link's prefetch. Only applies when `href` is set. */ prefetch?: boolean /** Open link in a new tab. Only applies when `href` is set. */ openInNewTab?: boolean /** * Render the action as a `SplitButton` (two independent click targets). * The main half runs `onClick`/`href`; the icon half runs its own action. * Mutually exclusive with `submenu`. */ iconAction?: SplitButtonIconAction /** * Render a button with a chevron that opens a dropdown. The whole button is * a single click target — clicking anywhere opens the menu. * Mutually exclusive with `iconAction` and `href`/`onClick`. */ submenu?: ActionsMenuItem[] /** When set, the rendered desktop button is wrapped in a hover tooltip. */ tooltip?: React.ReactNode } function actionKey(action: PageActionButton, idx: number) { return `${action.label ?? action.ariaLabel ?? 'action'}-${idx}` } function actionToMenuItems(action: PageActionButton, idx: number): ActionsMenuItem[] { if (action.submenu && action.submenu.length > 0) { // When a split-button action collapses into the merged mobile "..." menu, // its chevron disappears and its children become sibling rows. Prefix // each child with the parent label. if (!action.label) return action.submenu return action.submenu.map(item => ({ ...item, label: `${action.label} (${item.label})`, })) } if (!action.label) return [] return [{ id: `action-${idx}`, label: action.label, icon: action.icon, onClick: action.onClick, disabled: action.disabled, href: action.href, iconAction: action.iconAction ? { icon: action.iconAction.icon, 'aria-label': action.iconAction['aria-label'], onClick: action.iconAction.onClick as (() => void) | undefined, href: action.iconAction.href, openInNewTab: action.iconAction.openInNewTab, disabled: action.iconAction.disabled, } : undefined, }] } interface RenderOptions { /** Force the rendered button to be icon-only (label hidden). */ iconOnly?: boolean /** Stretch the button to fill flex parent (used in mobile bottom bar). */ fullWidth?: boolean } function renderActionButton(action: PageActionButton, opts: RenderOptions = {}): React.ReactNode { const button = renderRawActionButton(action, opts) if (!action.tooltip) return button return ( {button} {action.tooltip} ) } function renderRawActionButton(action: PageActionButton, opts: RenderOptions = {}): React.ReactNode { // Two-target SplitButton — primary action + secondary icon action. if (action.iconAction) { return ( {action.label} ) } // Submenu — single click target with a trailing chevron divider. if (action.submenu && action.submenu.length > 0) { return ( } className={opts.fullWidth ? 'flex-1' : undefined} > {action.label} } /> ) } // Icon-only button (no label, or explicitly icon-only on desktop). const isIconOnly = opts.iconOnly || !action.label || action.iconOnlyOnDesktop if (isIconOnly) { const iconNode = action.iconOnlyOnDesktop ? {action.icon} : action.icon return ( ) } export interface PageActionsProps { variant?: 'icon-buttons' | 'primary-buttons' | 'menu-primary' actions: PageActionButton[] menuActions?: ActionsMenuGroup[] /** * Desktop-only slot rendered before the action buttons (e.g. a `TabSelector` * for view-mode toggles). Hidden on mobile and never merged into the "…" menu. * Honored by the `icon-buttons` and `menu-primary` variants. */ selector?: React.ReactNode className?: string /** * Render placeholders instead of the actions — for pages whose action SET * depends on data still in flight. Opt-in: a page whose actions are already * final while its title loads keeps rendering them. */ loading?: boolean } const ACTIONS_GAP = 'gap-[var(--spacing-system-xs)]' /** Matches `Button` size="icon" / size="default" exactly, so a placeholder and the * button that replaces it occupy the same box at both breakpoints. `rounded-md` * is the same token `Button` itself uses — a raw `6px` would agree today and * drift the moment a platform overrides `--radius`. */ const SKELETON_HEIGHT = 'h-11 md:h-12' const SKELETON_ICON_WIDTH = 'w-11 md:w-12' const SKELETON_LABEL_WIDTH = 'w-[120px] md:w-[140px]' const SKELETON_RADIUS = 'rounded-md' /** * Placeholders for actions whose SHAPE isn't known yet — the page is still * loading the record that decides which actions exist (e.g. an archived entity * offers "Unarchive" where an active one offers "Edit"). * * Why not render the actions anyway: a button is a promise. Painting the * optimistic set means a flash of the wrong header AND a live click target that * can act on the wrong assumption before the data lands. A grey box promises * nothing and can't be clicked. * * The placeholders are derived from the actions the caller passed — one per * action, each sized like the button that action WILL render — so the common * case (the data confirms that set) settles with no layout shift at all. Sizing * per action matters: `renderRawActionButton` collapses a label-less action (or * one flagged `iconOnlyOnDesktop`) to a square `size="icon"` button, and a * 140px-wide placeholder snapping to 48px is exactly the shift this is meant to * avoid. * * An empty list still yields one placeholder: `loading` means the action set is * unknown, not known-empty. Its shape comes from `emptyFallback`, because what * an empty header settles into differs per variant — `icon-buttons` and * `menu-primary` fall back to the square "…" overflow trigger, `primary-buttons` * has no overflow trigger at all and settles into a labelled button. */ function ActionSkeletons({ actions, fullWidth, emptyFallback = 'icon', }: { actions: PageActionButton[] fullWidth?: boolean emptyFallback?: 'icon' | 'label' }) { const items = actions.length > 0 ? actions : [null] return ( <> {items.map((action, idx) => { // Mirrors `renderRawActionButton`'s `isIconOnly`. The mobile bottom bar // stretches only labelled buttons (`fullWidth: !!action.label`), so an // icon action keeps its square box there too. const isIconOnly = action ? !action.label || !!action.iconOnlyOnDesktop : emptyFallback === 'icon' return ( ) })} ) } /** Mobile placeholder for the variants that collapse to ONE trigger (icon button or "…"). */ function MobileTriggerSkeleton() { return } export function PageActions({ variant = 'icon-buttons', actions, menuActions, selector, className, loading, }: PageActionsProps) { if (variant === 'icon-buttons') { return } if (variant === 'menu-primary') { return } return } function IconButtonsVariant({ actions, menuActions, selector, className, loading, }: { actions: PageActionButton[] menuActions?: ActionsMenuGroup[] selector?: React.ReactNode className?: string loading?: boolean }) { const desktopActions = actions.filter(a => !a.showOnlyMobile) const hasMenuActions = !!menuActions && menuActions.some(g => g.items.length > 0) const isSingleAction = actions.length === 1 && !actions[0].submenu?.length const singleAction = isSingleAction ? actions[0] : null const useSingleActionMobile = isSingleAction && !hasMenuActions const mobileMenuGroups = [ { items: actions.flatMap(actionToMenuItems) }, ...(menuActions ?? []), ] const hasMobileMenuItems = mobileMenuGroups.some(g => g.items.length > 0) return ( <> {/* Desktop: every action as an icon button + optional overflow menu */}
{selector} {loading ? ( ) : ( <> {desktopActions.map((action, idx) => ( {renderActionButton(action)} ))} {hasMenuActions && } )}
{/* Mobile: single icon button OR all actions merged into one "..." menu */}
{loading ? ( ) : useSingleActionMobile && singleAction ? ( renderActionButton(singleAction, { iconOnly: true }) ) : hasMobileMenuItems ? ( ) : null}
) } /** * Primary buttons variant — primary + outline buttons on desktop, * fixed bottom bar on mobile. */ function PrimaryButtonsVariant({ actions, className, loading, }: { actions: PageActionButton[] className?: string loading?: boolean }) { // Sort: outline first, accent last (rightmost on desktop). const sortedActions = [...actions].sort((a, b) => { if (a.variant === 'accent' && b.variant !== 'accent') return 1 if (a.variant !== 'accent' && b.variant === 'accent') return -1 return 0 }) const desktopActions = sortedActions.filter(a => !a.showOnlyMobile) return ( <>
{loading ? ( // No overflow trigger in this variant — an unknown action set settles // into a labelled button, not a square one. ) : ( desktopActions.map((action, idx) => ( {renderActionButton(action)} )) )}
) } /** * Menu + primary variant — "..." menu + primary buttons on desktop, * all actions merged into a single "..." menu on mobile. */ function MenuPrimaryVariant({ actions, menuActions, selector, className, loading, }: { actions: PageActionButton[] menuActions: ActionsMenuGroup[] selector?: React.ReactNode className?: string loading?: boolean }) { const desktopActions = actions.filter(a => !a.showOnlyMobile) const hasMenuActions = menuActions.some(g => g.items.length > 0) const mobileMenuGroups = [ { items: actions.flatMap(actionToMenuItems) }, ...menuActions, ] const hasMobileMenuItems = mobileMenuGroups.some(g => g.items.length > 0) return ( <>
{selector} {loading ? ( ) : ( <> {hasMenuActions && } {desktopActions.map((action, idx) => ( {renderActionButton({ ...action, variant: action.variant || 'accent' })} ))} )}
{loading ? : hasMobileMenuItems ? : null}
) } function MobileBottomActions({ actions, loading }: { actions: PageActionButton[]; loading?: boolean }) { return (
{loading ? ( ) : ( actions.map((action, idx) => ( {renderActionButton(action, { fullWidth: !!action.label })} )) )}
) } export function usePageActionsBottomPadding(variant: PageActionsProps['variant']) { return variant === 'primary-buttons' || variant === 'menu-primary' ? 'pb-40 md:pb-0' : '' } export default PageActions