{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"gantt-bar","type":"registry:ui","title":"The interactive gantt bar - selection, clicks, drag + resize wiring, and the consumer render slot.","description":"The interactive gantt bar - selection, clicks, drag + resize wiring, and the consumer render slot.","dependencies":["@base-ui/react","cn"],"registryDependencies":["context-menu","@reui/gantt","@reui/gantt-dnd","@reui/gantt-lib","@reui/gantt-types","tooltip"],"files":[{"path":"gantt-bar.tsx","type":"registry:ui","content":"// Title: Gantt Bar\n// Description: The interactive gantt bar - selection, clicks, drag + resize wiring, and the consumer render slot.\n\n\"use client\"\n\nimport {\n  createContext,\n  useContext,\n  useMemo,\n  useState,\n  type CSSProperties,\n  type ReactNode,\n} from \"react\"\nimport {\n  useGantt,\n  useGanttSelector,\n  useGanttViewConfig,\n} from \"@/components/reui/gantt/gantt\"\nimport {\n  useGanttGestures,\n  wasRecentDrag,\n} from \"@/components/reui/gantt/gantt-dnd\"\nimport {\n  flattenResources,\n  getBaselineVariance,\n  resolveEventBaseline,\n  toZoned,\n} from \"@/components/reui/gantt/gantt-lib\"\nimport type {\n  GanttOccurrence,\n  GanttSegment,\n} from \"@/components/reui/gantt/gantt-types\"\nimport { mergeProps } from \"@base-ui/react/merge-props\"\nimport { useRender } from \"@base-ui/react/use-render\"\n\nimport { cn } from \"cn\"\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuTrigger,\n} from \"@/components/ui/context-menu\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { IconPlaceholder } from \"@/app/(create)/components/icon-placeholder\"\n\n/**\n * Effective Tailwind palette presets for bar colors; every entry works on\n * light and dark surfaces through the bar's alpha background + accent border.\n */\nconst GANTT_COLORS: Array<{ name: string; value: string }> = [\n  { name: \"Blue\", value: \"var(--color-blue-500)\" },\n  { name: \"Emerald\", value: \"var(--color-emerald-500)\" },\n  { name: \"Violet\", value: \"var(--color-violet-500)\" },\n  { name: \"Rose\", value: \"var(--color-rose-500)\" },\n  { name: \"Amber\", value: \"var(--color-amber-500)\" },\n  { name: \"Cyan\", value: \"var(--color-cyan-500)\" },\n  { name: \"Orange\", value: \"var(--color-orange-500)\" },\n  { name: \"Pink\", value: \"var(--color-pink-500)\" },\n  { name: \"Teal\", value: \"var(--color-teal-500)\" },\n  { name: \"Indigo\", value: \"var(--color-indigo-500)\" },\n]\n\ninterface GanttBarContextValue<TData = unknown> {\n  occurrence: GanttOccurrence<TData>\n  segment: GanttSegment<TData>\n  isDragging: boolean\n  isSelected: boolean\n}\n\nconst GanttBarContext =\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  createContext<GanttBarContextValue<any> | null>(null)\n\n/** The bar's subject; usable inside renderEvent content and bar children. */\nfunction useGanttBarContext<TData = unknown>(): GanttBarContextValue<TData> {\n  const ctx = useContext(GanttBarContext)\n  if (!ctx) {\n    throw new Error(\"useGanttBarContext must be used within <GanttBar>\")\n  }\n  return ctx as GanttBarContextValue<TData>\n}\n\ninterface GanttBarProps<TData = unknown> extends Omit<\n  useRender.ComponentProps<\"button\">,\n  \"children\"\n> {\n  segment: GanttSegment<TData>\n  /** Replaces the default bar CONTENT; the wrapper stays gantt-owned. */\n  children?: ReactNode\n  /**\n   * The title renders beside the bar (view-owned), so the default inner\n   * content is suppressed. Explicit children and renderEvent still win.\n   */\n  labelOutside?: boolean\n  /**\n   * The owning row's title for the aria-label. Pass it when the row is in\n   * scope (the internal view does); omitting falls back to a tree lookup.\n   */\n  rowTitle?: string\n}\n\n/**\n * The one interactive bar element. The wrapper owns positioning hooks, a11y,\n * selection, drag/resize listeners, and data attributes; content comes from\n * children, the root renderEvent override, or the built-in default.\n */\nfunction GanttBar<TData = unknown>({\n  segment,\n  className,\n  render,\n  children,\n  labelOutside,\n  rowTitle: rowTitleProp,\n  ...props\n}: GanttBarProps<TData>) {\n  const instance = useGantt<TData>()\n  const viewConfig = useGanttViewConfig<TData>()\n  const gestures = useGanttGestures<TData>()\n  const { settings } = instance\n  const occurrence = segment.occurrence\n  const event = occurrence.event\n\n  const isSelected = useGanttSelector<TData, boolean>(\n    (state) => state.selection.eventKeys.includes(occurrence.key),\n    { calendar: instance }\n  )\n  const isDragging = useGanttSelector<TData, boolean>(\n    (state) => state.drag?.occurrence.key === occurrence.key,\n    { calendar: instance }\n  )\n  // Which gesture owns this bar: a move hides the original (the smooth clone\n  // stands in for it); a resize keeps it as a faint placeholder behind the\n  // dashed preview so you can see the original extent.\n  const dragKind = useGanttSelector<TData, string | null>(\n    (state) =>\n      state.drag?.occurrence.key === occurrence.key ? state.drag.kind : null,\n    { calendar: instance }\n  )\n  // Hover-only range tooltip. Focus opens are ignored (the known button+\n  // tooltip flash: clicking a bar opens a dialog, focus returns, and a\n  // focus-triggered tooltip would pop). Hidden while dragging/resizing.\n  const [tipOpen, setTipOpen] = useState(false)\n  // Gated on tipOpen: with the tooltip closed the selector returns a stable\n  // false, so gesture start/end doesn't re-render every mounted bar.\n  const anyInteracting = useGanttSelector<TData, boolean>(\n    (state) => tipOpen && (state.drag !== null || state.slotDraft !== null),\n    { calendar: instance }\n  )\n\n  const progress =\n    typeof event.progress === \"number\"\n      ? Math.min(Math.max(Math.round(event.progress), 0), 100)\n      : null\n\n  // A zero-duration occurrence is a MILESTONE: the button stays the\n  // interactive shell (click, select, menu, move), the diamond replaces the\n  // tinted bar body, and the view keeps the title outside.\n  const milestone = occurrence.end.getTime() === occurrence.start.getTime()\n\n  // The bar only LABELS its baseline (attributes, tooltip, aria); the ghost\n  // itself is view-owned chrome, because its range differs from this bar's.\n  const baseline = viewConfig.baselineBars ? resolveEventBaseline(event) : null\n  const baselineVariance = baseline ? getBaselineVariance(event) : null\n\n  const defaultContent = (\n    <>\n      {occurrence.isRecurring && (\n        <IconPlaceholder\n          lucide=\"RepeatIcon\"\n          tabler=\"IconRepeat\"\n          hugeicons=\"RepeatIcon\"\n          phosphor=\"RepeatIcon\"\n          remixicon=\"RiRepeatLine\"\n          className=\"size-2.5 shrink-0 opacity-70\"\n          aria-hidden=\"true\"\n        />\n      )}\n      <span className=\"truncate font-medium\">{event.title}</span>\n      {!occurrence.allDay && segment.isStart && (\n        <span className=\"text-muted-foreground hidden truncate @[8rem]:inline\">\n          {settings.i18n.functions.formatEventTime(\n            toZoned(occurrence.start, settings.timeZone),\n            toZoned(occurrence.end, settings.timeZone),\n            occurrence.allDay,\n            settings.locale\n          )}\n        </span>\n      )}\n    </>\n  )\n\n  const renderProps = { occurrence, segment, isDragging, isSelected }\n  const content =\n    children ??\n    viewConfig.renderEvent?.(renderProps) ??\n    (labelOutside || milestone ? null : defaultContent)\n  // Consumer-owned content owns the WHOLE inner visualization: the built-in\n  // progress fill and done mark yield so custom bars start from a blank\n  // canvas (progress stays readable via data-progress/data-completed).\n  const consumerOwnsContent = children !== undefined || !!viewConfig.renderEvent\n\n  const timeLabel = settings.i18n.functions.formatEventTime(\n    toZoned(occurrence.start, settings.timeZone),\n    toZoned(occurrence.end, settings.timeZone),\n    occurrence.allDay,\n    settings.locale\n  )\n  // Memoized: a second full formatEventTime per render would double the\n  // bar's formatting cost, and it cannot wait for the tooltip because the\n  // label also feeds aria-label. Keyed on the instants, not the baseline\n  // object - resolveEventBaseline allocates a fresh one every render.\n  const baselineStartMs = baseline?.start.getTime()\n  const baselineEndMs = baseline?.end.getTime()\n  const plannedLabel = useMemo(\n    () =>\n      baselineStartMs === undefined || baselineEndMs === undefined\n        ? undefined\n        : settings.i18n.labels.planned(\n            settings.i18n.functions.formatEventTime(\n              toZoned(new Date(baselineStartMs), settings.timeZone),\n              toZoned(new Date(baselineEndMs), settings.timeZone),\n              occurrence.allDay,\n              settings.locale\n            )\n          ),\n    [\n      baselineStartMs,\n      baselineEndMs,\n      occurrence.allDay,\n      settings.i18n,\n      settings.timeZone,\n      settings.locale,\n    ]\n  )\n  // name the row too: the split-pane layout carries no grid semantics.\n  // The prop path is O(1); the lookup fallback is memoized so external\n  // GanttBar usage never flattens the tree per render.\n  const fallbackRowTitle = useMemo(\n    () =>\n      rowTitleProp === undefined && event.resourceId\n        ? flattenResources(settings.resources).find(\n            ({ resource }) => resource.id === event.resourceId\n          )?.resource.title\n        : undefined,\n    [rowTitleProp, event.resourceId, settings.resources]\n  )\n  const rowTitle = rowTitleProp ?? fallbackRowTitle\n\n  const showResize = gestures.canResize(segment)\n  const resizeHandles = showResize && (\n    <>\n      {segment.isStart && (\n        <span\n          data-slot=\"gantt-resize-handle\"\n          data-edge=\"start\"\n          // grip hugs the start edge (justify-start + tight inset) so the\n          // indicator reads as \"resize this end\", not a centered pill.\n          // pointer-coarse keeps it visible on touch, where hover never fires\n          className=\"absolute inset-y-0 start-0.5 flex w-2 cursor-ew-resize items-center justify-start opacity-0 group-hover/gantt-bar-group:opacity-100 pointer-coarse:opacity-100\"\n          onPointerDown={(e) => gestures.beginResize(e, segment, \"start\")}\n        >\n          <span\n            aria-hidden\n            className=\"bg-foreground/40 h-2.5 w-0.5 rounded-full\"\n          />\n        </span>\n      )}\n      {segment.isEnd && (\n        <span\n          data-slot=\"gantt-resize-handle\"\n          data-edge=\"end\"\n          // grip hugs the end edge (justify-end + tight inset) so the\n          // indicator reads as \"resize this end\", not a centered pill.\n          // pointer-coarse keeps it visible on touch, where hover never fires\n          className=\"absolute inset-y-0 end-0.5 flex w-2 cursor-ew-resize items-center justify-end opacity-0 group-hover/gantt-bar-group:opacity-100 pointer-coarse:opacity-100\"\n          onPointerDown={(e) => gestures.beginResize(e, segment, \"end\")}\n        >\n          <span\n            aria-hidden\n            className=\"bg-foreground/40 h-2.5 w-0.5 rounded-full\"\n          />\n        </span>\n      )}\n    </>\n  )\n\n  const defaultProps = {\n    type: \"button\" as const,\n    \"data-slot\": \"gantt-bar\",\n    \"data-milestone\": milestone || undefined,\n    \"data-all-day\": occurrence.allDay || undefined,\n    \"data-recurring\": occurrence.isRecurring || undefined,\n    \"data-selected\": isSelected || undefined,\n    \"data-dragging\": isDragging || undefined,\n    \"data-drag-kind\": dragKind ?? undefined,\n    \"data-past\": occurrence.end.getTime() < Date.now() || undefined,\n    \"data-label-outside\": labelOutside || undefined,\n    \"data-progress\": progress ?? undefined,\n    \"data-completed\": progress === 100 || undefined,\n    \"data-baseline\": !!baseline || undefined,\n    \"data-baseline-variance\": baselineVariance ?? undefined,\n    \"aria-label\": settings.i18n.functions.formatEventAriaLabel({\n      title: event.title,\n      timeLabel,\n      milestoneLabel: milestone ? settings.i18n.labels.milestone : undefined,\n      rowTitle,\n      progressLabel:\n        progress !== null ? settings.i18n.labels.progress(progress) : undefined,\n      plannedLabel,\n      continues: segment.continuesBefore || segment.continuesAfter,\n    }),\n    style: {\n      \"--gantt-event-color\": event.color ?? \"var(--color-primary)\",\n    } as CSSProperties,\n    onPointerDown: (e: React.PointerEvent) => {\n      e.stopPropagation()\n      gestures.beginMove(e, segment)\n    },\n    onClick: (e: React.MouseEvent) => {\n      e.stopPropagation()\n      if (wasRecentDrag()) return\n      instance.api.selectEvent(occurrence.key)\n      settings.onEventClick?.(occurrence, e)\n    },\n    onDoubleClick: (e: React.MouseEvent) => {\n      e.stopPropagation()\n      settings.onEventDoubleClick?.(occurrence, e)\n    },\n    className: cn(\n      \"group/gantt-bar-group text-foreground @container relative flex w-full min-w-0 cursor-pointer touch-none items-center gap-1.5 overflow-hidden rounded-sm px-1.5 py-0.5 text-start leading-normal select-none\",\n      \"focus-visible:ring-ring/50 outline-none focus-visible:ring-2\",\n      // the unfilled remainder has to be legible on its own - at /12 a bar\n      // with a progress fill read as a floating segment with no basement\n      \"bg-(--gantt-event-color)/20 hover:bg-(--gantt-event-color)/30\",\n      // move: hide the original (the smooth cursor clone represents it)\n      \"data-[drag-kind=move]:opacity-0\",\n      // resize: keep the original event exactly, just fade it to a soft\n      // placeholder behind the dashed preview - no dramatic restyle\n      \"data-[drag-kind=resize-start]:opacity-40 data-[drag-kind=resize-end]:opacity-40\",\n      \"data-selected:bg-(--gantt-event-color)/30\",\n      /* the diamond is the milestone's body, so the shell sheds its own\n         tinted fill and centers the glyph on the instant */\n      milestone &&\n        \"justify-center bg-transparent px-0 hover:bg-transparent data-selected:bg-transparent\",\n      segment.continuesBefore && \"rounded-s-none\",\n      segment.continuesAfter && \"rounded-e-none\",\n      viewConfig.classNames?.event,\n      className\n    ),\n    children: (\n      <>\n        {milestone && !consumerOwnsContent && (\n          // the filled diamond IS the milestone's body - chrome, so a custom\n          // renderEvent still starts from a blank canvas (data-milestone\n          // keeps the fact readable there)\n          <span\n            aria-hidden\n            data-slot=\"gantt-bar-milestone\"\n            className={cn(\n              \"size-2.5 shrink-0 rotate-45 rounded-[2px] border border-(--gantt-event-color) bg-(--gantt-event-color)/80\",\n              isSelected && \"ring-ring/50 ring-2\"\n            )}\n          />\n        )}\n        {progress !== null && !milestone && (\n          // Chrome, not content: it is an absolutely-positioned layer BEHIND\n          // whatever the bar renders, so a consumer bar (renderEvent) keeps\n          // its completion fill instead of silently losing it. The inline\n          // done-mark below stays gated, because that one really is content.\n          <span\n            aria-hidden\n            data-slot=\"gantt-bar-progress\"\n            className=\"pointer-events-none absolute inset-y-0 start-0 border-e border-(--gantt-event-color)/65 bg-(--gantt-event-color)/40 data-full:border-e-0\"\n            data-full={progress === 100 || undefined}\n            style={{ width: `${progress}%` }}\n          />\n        )}\n        {progress === 100 && !consumerOwnsContent && !milestone && (\n          // done mark: completion chrome like the fill itself, so it shows\n          // for outside-label bars too (where the inner content is empty)\n          <IconPlaceholder\n            lucide=\"CheckIcon\"\n            tabler=\"IconCheck\"\n            hugeicons=\"Tick02Icon\"\n            phosphor=\"CheckIcon\"\n            remixicon=\"RiCheckLine\"\n            className=\"relative size-2.5 shrink-0 opacity-80\"\n            aria-hidden=\"true\"\n          />\n        )}\n        {content}\n        {resizeHandles}\n      </>\n    ),\n  }\n\n  const barButton = useRender({\n    defaultTagName: \"button\",\n    render,\n    props: mergeProps<\"button\">(defaultProps, props),\n  })\n\n  // Consumer-owned right-click menu (headless): the primitive only wires the\n  // ContextMenu; the items and their handlers come entirely from the block.\n  const menu = viewConfig.renderEventMenu?.(renderProps)\n\n  // The bar is simultaneously the tooltip trigger and (when a menu exists)\n  // the context-menu trigger; Base UI composes both via render props.\n  const trigger = menu ? (\n    <ContextMenuTrigger render={<TooltipTrigger render={barButton} />} />\n  ) : (\n    <TooltipTrigger render={barButton} />\n  )\n\n  const barTree = (\n    <TooltipProvider delay={500} closeDelay={0} timeout={300}>\n      <Tooltip\n        open={tipOpen && !anyInteracting}\n        onOpenChange={(next: boolean, details: { reason?: string }) => {\n          // opens only on hover; focus/press opens are dropped\n          if (next && details?.reason !== \"trigger-hover\") return\n          setTipOpen(next)\n        }}\n      >\n        {trigger}\n        {tipOpen && !anyInteracting && (\n          <TooltipContent side=\"top\" className=\"pointer-events-none\">\n            <div className=\"font-medium\">{event.title}</div>\n            <div className=\"opacity-80\">{timeLabel}</div>\n            {plannedLabel && <div className=\"opacity-80\">{plannedLabel}</div>}\n          </TooltipContent>\n        )}\n      </Tooltip>\n    </TooltipProvider>\n  )\n\n  return (\n    <GanttBarContext.Provider\n      value={{ occurrence, segment, isDragging, isSelected }}\n    >\n      {menu ? (\n        <ContextMenu>\n          {barTree}\n          <ContextMenuContent data-slot=\"gantt-bar-menu\" className=\"min-w-44\">\n            {menu}\n          </ContextMenuContent>\n        </ContextMenu>\n      ) : (\n        barTree\n      )}\n    </GanttBarContext.Provider>\n  )\n}\n\nexport { GANTT_COLORS, GanttBar, useGanttBarContext }\nexport type { GanttBarContextValue, GanttBarProps }","target":"components/reui/gantt/gantt-bar.tsx"}]}