{"name":"change-review","type":"registry:component","files":[{"path":"change-review.tsx","type":"registry:component","target":"components/ui/change-review/change-review.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport {\n  ArrowRight as ArrowRightIcon,\n  Check as CheckIcon,\n  Minus as MinusIcon,\n  Pencil as PencilIcon,\n  Plus as PlusIcon,\n  X as CloseIcon,\n} from \"honestui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Switch } from \"@/components/ui/switch\"\n\nimport {\n  ChangeReviewControllerContext,\n  useChangeReviewController,\n} from \"./change-review-context\"\nimport type {\n  ChangeReviewChange,\n  ChangeReviewControllerValue,\n  ChangeReviewDecision,\n  ChangeReviewDecisions,\n  ChangeReviewFieldNode,\n  ChangeReviewGroupNode,\n  ChangeReviewLabels,\n  ChangeReviewListChange,\n  ChangeReviewValueChange,\n} from \"./change-review-types\"\nimport {\n  countChanges,\n  decideAllChanges,\n  defaultChangeReviewLabels,\n  getChangeStatus,\n  getChanges,\n  groupChanges,\n  isEmptyValue,\n} from \"./change-review-utils\"\n\nexport type {\n  ChangeReviewChange,\n  ChangeReviewControllerValue,\n  ChangeReviewCounts,\n  ChangeReviewDecision,\n  ChangeReviewDecisions,\n  ChangeReviewField,\n  ChangeReviewFieldGroup,\n  ChangeReviewFieldNode,\n  ChangeReviewLabels,\n  ChangeReviewListChange,\n  ChangeReviewListField,\n  ChangeReviewStatus,\n  ChangeReviewValueChange,\n  ChangeReviewValueField,\n} from \"./change-review-types\"\nexport {\n  applyAcceptedChanges,\n  defaultChangeReviewLabels,\n  getChangeStatus,\n  getChanges,\n} from \"./change-review-utils\"\nexport { useChangeReviewController } from \"./change-review-context\"\n\nconst secondaryText =\n  \"text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]\"\nconst bodyText =\n  \"[font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]\"\n\ntype ChangeReviewSource =\n  | {\n      /** The saved version of the object. */\n      before: unknown\n      /** The proposed or resulting version of the object. */\n      after: unknown\n      /** Which fields to compare, how to label them, and how to group them. */\n      fields: ChangeReviewFieldNode[]\n      changes?: never\n    }\n  | {\n      /** Change records your application already holds, such as an audit entry. */\n      changes: ChangeReviewChange[]\n      before?: never\n      after?: never\n      fields?: never\n    }\n\nexport type ChangeReviewProps = Omit<React.ComponentProps<\"section\">, \"onChange\"> &\n  ChangeReviewSource & {\n    /** Hides fields that did not change. Controlled. */\n    changedOnly?: boolean | undefined\n    defaultChangedOnly?: boolean\n    onChangedOnlyChange?: ((changedOnly: boolean) => void) | undefined\n    /** Accept and reject choices keyed by change id. Controlled. */\n    decisions?: ChangeReviewDecisions | undefined\n    defaultDecisions?: ChangeReviewDecisions | undefined\n    /** Passing this handler shows the accept and reject controls. */\n    onDecisionsChange?: ((decisions: ChangeReviewDecisions) => void) | undefined\n    /** Keeps the controls visible but inactive, for example while saving. */\n    disabled?: boolean\n    /** Display for any value whose field has no `render` of its own. */\n    renderValue?: ((value: unknown, change: ChangeReviewValueChange) => React.ReactNode) | undefined\n    labels?: Partial<ChangeReviewLabels> | undefined\n    /** List items shown before the rest collapse behind a button. */\n    maxListItems?: number\n  }\n\nfunction ChangeReview({\n  before,\n  after,\n  fields,\n  changes: changesProp,\n  changedOnly: changedOnlyProp,\n  defaultChangedOnly = true,\n  onChangedOnlyChange,\n  decisions: decisionsProp,\n  defaultDecisions,\n  onDecisionsChange,\n  disabled = false,\n  renderValue,\n  labels: labelsProp,\n  maxListItems = 5,\n  className,\n  children,\n  ...props\n}: ChangeReviewProps) {\n  const changes = React.useMemo(\n    () => changesProp ?? getChanges(before, after, fields ?? []),\n    [changesProp, before, after, fields]\n  )\n\n  const [internalChangedOnly, setInternalChangedOnly] = React.useState(defaultChangedOnly)\n  const changedOnly = changedOnlyProp ?? internalChangedOnly\n  const setChangedOnly = React.useCallback(\n    (next: boolean) => {\n      setInternalChangedOnly(next)\n      onChangedOnlyChange?.(next)\n    },\n    [onChangedOnlyChange]\n  )\n\n  const [internalDecisions, setInternalDecisions] = React.useState<ChangeReviewDecisions>(\n    defaultDecisions ?? {}\n  )\n  const decisions = decisionsProp ?? internalDecisions\n  const commitDecisions = React.useCallback(\n    (next: ChangeReviewDecisions) => {\n      setInternalDecisions(next)\n      onDecisionsChange?.(next)\n    },\n    [onDecisionsChange]\n  )\n\n  const labels = React.useMemo(\n    () => ({ ...defaultChangeReviewLabels, ...labelsProp }),\n    [labelsProp]\n  )\n\n  const controller = React.useMemo((): ChangeReviewControllerValue => {\n    const counts = countChanges(changes, decisions)\n\n    return {\n      changes,\n      visibleChanges: changedOnly\n        ? changes.filter((change) => getChangeStatus(change) !== \"unchanged\")\n        : changes,\n      counts,\n      hasUnchanged: counts.changed < counts.total,\n      changedOnly,\n      setChangedOnly,\n      canDecide: onDecisionsChange !== undefined,\n      disabled,\n      decisions,\n      decide: (id, decision) => {\n        const next = { ...decisions }\n        if (decision === null) delete next[id]\n        else next[id] = decision\n        commitDecisions(next)\n      },\n      decideAll: (decision) => commitDecisions(decideAllChanges(changes, decision)),\n      labels,\n      renderValue,\n      maxListItems,\n    }\n  }, [\n    changes,\n    decisions,\n    changedOnly,\n    setChangedOnly,\n    onDecisionsChange,\n    disabled,\n    commitDecisions,\n    labels,\n    renderValue,\n    maxListItems,\n  ])\n\n  return (\n    <ChangeReviewControllerContext.Provider value={controller}>\n      <section\n        data-slot=\"change-review-root\"\n        className={cn(\"@container flex w-full min-w-0 flex-col gap-[var(--hui-space-3)]\", className)}\n        {...props}\n      >\n        {children ?? (\n          <>\n            <ChangeReviewHeader />\n            <ChangeReviewList />\n          </>\n        )}\n      </section>\n    </ChangeReviewControllerContext.Provider>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Header                                                                     */\n/* -------------------------------------------------------------------------- */\n\n/** Summary on the left; the filter and bulk actions on the right when they apply. */\nfunction ChangeReviewHeader({ className, children, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"change-review-header\"\n      className={cn(\n        \"flex flex-wrap items-center justify-between gap-x-[var(--hui-space-4)] gap-y-[var(--hui-space-2)]\",\n        className\n      )}\n      {...props}\n    >\n      {children ?? (\n        <>\n          <ChangeReviewSummary />\n          <div className=\"flex flex-wrap items-center gap-x-[var(--hui-space-4)] gap-y-[var(--hui-space-2)]\">\n            <ChangeReviewFilter />\n            <ChangeReviewBulkActions />\n          </div>\n        </>\n      )}\n    </div>\n  )\n}\n\n/** Counts of changes and decisions. Announced politely as decisions are made. */\nfunction ChangeReviewSummary({ className, ...props }: React.ComponentProps<\"p\">) {\n  const { counts, canDecide, labels } = useChangeReviewController(\"ChangeReviewSummary\")\n\n  return (\n    <p\n      data-slot=\"change-review-summary\"\n      role=\"status\"\n      className={cn(\n        \"m-0 text-[var(--hui-color-foreground-base-primary)] [font-weight:var(--hui-font-weight-medium)]\",\n        bodyText,\n        className\n      )}\n      {...props}\n    >\n      {labels.summary(counts)}\n      {canDecide && counts.changed > 0 && (\n        <>\n          {/* A real separator, so the two counts do not run together when read aloud. */}\n          <span className=\"sr-only\">. </span>\n          <span\n            className={cn(\n              \"ml-[var(--hui-space-2)] [font-weight:var(--hui-font-weight-regular)]\",\n              secondaryText\n            )}\n          >\n            {labels.decisionSummary(counts)}\n          </span>\n        </>\n      )}\n    </p>\n  )\n}\n\n/** Switch that hides unchanged fields. Absent when every field changed. */\nfunction ChangeReviewFilter({ className, ...props }: React.ComponentProps<\"div\">) {\n  const { hasUnchanged, changedOnly, setChangedOnly, labels } =\n    useChangeReviewController(\"ChangeReviewFilter\")\n  const labelId = React.useId()\n\n  if (!hasUnchanged) return null\n\n  return (\n    <div\n      data-slot=\"change-review-filter\"\n      className={cn(\n        \"inline-flex min-h-6 items-center gap-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-primary)]\",\n        bodyText,\n        className\n      )}\n      {...props}\n    >\n      {/* The switch is not a form control a <label> can name, so it is named by reference. */}\n      <Switch\n        aria-labelledby={labelId}\n        checked={changedOnly}\n        onCheckedChange={setChangedOnly}\n        // Forced colors removes the track and thumb fills, so draw both with system colors.\n        className={cn(forcedColorsBorder, \"forced-colors:box-content forced-colors:[&>span]:bg-[ButtonText]\")}\n      />\n      <span id={labelId} className=\"cursor-pointer\" onClick={() => setChangedOnly(!changedOnly)}>\n        {labels.changedOnly}\n      </span>\n    </div>\n  )\n}\n\n/** Filled buttons lose their fill in forced colors, so they get an edge instead. */\nconst forcedColorsBorder = \"forced-colors:border forced-colors:border-[ButtonText]\"\n\n/** Accept all and Reject all. Absent unless the application handles decisions. */\nfunction ChangeReviewBulkActions({ className, ...props }: React.ComponentProps<\"div\">) {\n  const { canDecide, counts, disabled, decideAll, labels } =\n    useChangeReviewController(\"ChangeReviewBulkActions\")\n\n  if (!canDecide || counts.changed === 0) return null\n\n  return (\n    <div\n      data-slot=\"change-review-bulk-actions\"\n      className={cn(\"flex items-center gap-[var(--hui-space-2)]\", className)}\n      {...props}\n    >\n      <Button\n        variant=\"secondary\"\n        size=\"sm\"\n        className={forcedColorsBorder}\n        disabled={disabled || counts.rejected === counts.changed}\n        onClick={() => decideAll(\"rejected\")}\n      >\n        {labels.rejectAll}\n      </Button>\n      <Button\n        variant=\"secondary\"\n        size=\"sm\"\n        className={forcedColorsBorder}\n        disabled={disabled || counts.accepted === counts.changed}\n        onClick={() => decideAll(\"accepted\")}\n      >\n        {labels.acceptAll}\n      </Button>\n    </div>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* List                                                                       */\n/* -------------------------------------------------------------------------- */\n\n/** Bordered list of changes, nested under their groups. */\nfunction ChangeReviewList({ className, ...props }: React.ComponentProps<\"div\">) {\n  const { visibleChanges, changes, labels } = useChangeReviewController(\"ChangeReviewList\")\n  const tree = React.useMemo(() => groupChanges(visibleChanges), [visibleChanges])\n\n  if (visibleChanges.length === 0) {\n    return (\n      <p\n        data-slot=\"change-review-empty\"\n        className={cn(\n          \"m-0 rounded-[var(--hui-radius-2)] border-[0.5px] border-dashed border-[var(--hui-color-border-base-primary)] px-[var(--hui-space-4)] py-[var(--hui-space-5)] text-center\",\n          secondaryText,\n          bodyText\n        )}\n      >\n        {changes.length === 0 ? labels.empty : labels.emptyFiltered}\n      </p>\n    )\n  }\n\n  return (\n    <div\n      data-slot=\"change-review-list\"\n      className={cn(\n        \"w-full min-w-0 overflow-hidden rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-base-secondary)]\",\n        className\n      )}\n      {...props}\n    >\n      <GroupBody node={tree} depth={0} />\n    </div>\n  )\n}\n\nconst divided =\n  \"[&>*+*]:border-t-[0.5px] [&>*+*]:border-t-[var(--hui-color-border-base-secondary)]\"\n\nfunction GroupBody({ node, depth }: { node: ChangeReviewGroupNode; depth: number }) {\n  return (\n    <div className={divided}>\n      {node.changes.length > 0 && (\n        <dl className={cn(\"m-0\", divided)}>\n          {node.changes.map((change) => (\n            <ChangeRow key={change.id} change={change} depth={depth} />\n          ))}\n        </dl>\n      )}\n      {node.groups.map((group) => (\n        <Group key={group.id} node={group} depth={depth} />\n      ))}\n    </div>\n  )\n}\n\n/** Each level of nesting indents its heading and labels by one step; values stay aligned. */\nfunction indent(depth: number): React.CSSProperties {\n  return { paddingInlineStart: `calc(var(--hui-space-4) * ${depth})` }\n}\n\nfunction Group({ node, depth }: { node: ChangeReviewGroupNode; depth: number }) {\n  const headingId = React.useId()\n\n  return (\n    <div data-slot=\"change-review-group\" role=\"group\" aria-labelledby={headingId}>\n      <div\n        id={headingId}\n        data-slot=\"change-review-group-label\"\n        className={cn(\n          \"bg-[var(--hui-color-background-neutral-secondary)] px-[var(--hui-space-4)] py-[var(--hui-space-2)] [font-weight:var(--hui-font-weight-medium)]\",\n          secondaryText\n        )}\n      >\n        <span className=\"block\" style={indent(depth)}>\n          {node.label}\n        </span>\n      </div>\n      <div className=\"border-t-[0.5px] border-t-[var(--hui-color-border-base-secondary)]\">\n        <GroupBody node={node} depth={depth + 1} />\n      </div>\n    </div>\n  )\n}\n\nfunction ChangeRow({ change, depth }: { change: ChangeReviewChange; depth: number }) {\n  const { canDecide } = useChangeReviewController(\"ChangeReviewRow\")\n  const status = getChangeStatus(change)\n\n  return (\n    <div\n      data-slot=\"change-review-row\"\n      data-status={status}\n      className=\"grid grid-cols-1 items-start gap-x-[var(--hui-space-4)] gap-y-[var(--hui-space-1)] px-[var(--hui-space-4)] py-[var(--hui-space-3)] @lg:grid-cols-[minmax(0,12rem)_minmax(0,1fr)_auto]\"\n    >\n      <dt\n        data-slot=\"change-review-label\"\n        style={indent(depth)}\n        className={cn(\n          \"min-w-0 break-words text-[var(--hui-color-foreground-base-secondary)]\",\n          bodyText\n        )}\n      >\n        {change.label}\n      </dt>\n      <dd\n        data-slot=\"change-review-value\"\n        className={cn(\"m-0 min-w-0 text-[var(--hui-color-foreground-base-primary)]\", bodyText)}\n      >\n        {change.kind === \"list\" ? (\n          <ListChange change={change} />\n        ) : (\n          <ValueChange change={change} status={status} />\n        )}\n      </dd>\n      {canDecide && status !== \"unchanged\" && (\n        <dd className=\"m-0 @lg:col-start-3\">\n          <DecisionControls change={change} />\n        </dd>\n      )}\n    </div>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Values                                                                     */\n/* -------------------------------------------------------------------------- */\n\nfunction DisplayValue({ change, value }: { change: ChangeReviewValueChange; value: unknown }) {\n  const { labels, renderValue } = useChangeReviewController(\"ChangeReviewValue\")\n\n  if (isEmptyValue(value)) {\n    return <span className=\"text-[var(--hui-color-foreground-base-secondary)]\">{labels.notSet}</span>\n  }\n  if (change.render) return <>{change.render(value)}</>\n  if (renderValue) return <>{renderValue(value, change)}</>\n  if (typeof value === \"boolean\") return <>{value ? labels.enabled : labels.disabled}</>\n  if (typeof value === \"number\") return <>{value.toLocaleString()}</>\n  if (value instanceof Date) return <>{value.toLocaleDateString()}</>\n  if (Array.isArray(value)) return <>{value.map(String).join(\", \")}</>\n  // No renderer was given for a structured value, so show it as written.\n  if (typeof value === \"object\") return <code className=\"break-all\">{JSON.stringify(value)}</code>\n  return <>{String(value)}</>\n}\n\nfunction ValueChange({\n  change,\n  status,\n}: {\n  change: ChangeReviewValueChange\n  status: ReturnType<typeof getChangeStatus>\n}) {\n  const { labels } = useChangeReviewController(\"ChangeReviewValue\")\n\n  if (status === \"unchanged\") {\n    return (\n      <span className=\"flex flex-wrap items-baseline gap-x-[var(--hui-space-3)]\">\n        <span className=\"min-w-0 break-words\">\n          <DisplayValue change={change} value={change.after} />\n        </span>\n        <span className={secondaryText}>{labels.noChange}</span>\n      </span>\n    )\n  }\n\n  return (\n    <span className=\"flex flex-wrap items-center gap-x-[var(--hui-space-2)] gap-y-[var(--hui-space-1)]\">\n      <span className=\"sr-only\">{labels.was} </span>\n      <span\n        data-slot=\"change-review-before\"\n        className={cn(\n          \"min-w-0 break-words text-[var(--hui-color-foreground-base-secondary)]\",\n          // \"Not set\" is a statement about the old value, not the old value itself.\n          !isEmptyValue(change.before) && \"line-through\"\n        )}\n      >\n        <DisplayValue change={change} value={change.before} />\n      </span>\n      <ArrowRightIcon\n        aria-hidden=\"true\"\n        className=\"size-3.5 shrink-0 text-[var(--hui-color-foreground-base-secondary)] rtl:rotate-180\"\n      />\n      <span className=\"sr-only\">, {labels.now} </span>\n      <span\n        data-slot=\"change-review-after\"\n        className=\"min-w-0 break-words [font-weight:var(--hui-font-weight-medium)]\"\n      >\n        <DisplayValue change={change} value={change.after} />\n      </span>\n    </span>\n  )\n}\n\ntype ListEntry = { tone: \"added\" | \"removed\" | \"changed\"; item: unknown }\n\nconst entryIcon = { added: PlusIcon, removed: MinusIcon, changed: PencilIcon }\nconst entryTone = {\n  added: \"text-[var(--hui-color-foreground-success-primary)]\",\n  removed: \"text-[var(--hui-color-foreground-danger-primary)]\",\n  changed: \"text-[var(--hui-color-foreground-base-secondary)]\",\n}\n\nfunction ListChange({ change }: { change: ChangeReviewListChange }) {\n  const { labels, maxListItems } = useChangeReviewController(\"ChangeReviewValue\")\n  const [expanded, setExpanded] = React.useState(false)\n  const listId = React.useId()\n\n  const entries: ListEntry[] = [\n    ...change.added.map((item) => ({ tone: \"added\" as const, item })),\n    ...change.removed.map((item) => ({ tone: \"removed\" as const, item })),\n    ...(change.changed ?? []).map((item) => ({ tone: \"changed\" as const, item })),\n  ]\n\n  if (entries.length === 0) {\n    return <span className={secondaryText}>{labels.noChange}</span>\n  }\n\n  const overflow = entries.length - maxListItems\n  const shown = expanded || overflow <= 0 ? entries : entries.slice(0, maxListItems)\n  const itemLabel = change.itemLabel as ((item: unknown) => React.ReactNode) | undefined\n\n  return (\n    <div className=\"flex min-w-0 flex-col gap-[var(--hui-space-2)]\">\n      <span data-slot=\"change-review-list-summary\" className=\"[font-weight:var(--hui-font-weight-medium)]\">\n        {labels.listSummary({\n          added: change.added.length,\n          removed: change.removed.length,\n          changed: change.changed?.length ?? 0,\n        })}\n      </span>\n      <ul id={listId} className=\"m-0 flex list-none flex-col gap-[var(--hui-space-1)] p-0\">\n        {shown.map((entry, index) => {\n          const Icon = entryIcon[entry.tone]\n          return (\n            <li\n              key={index}\n              data-slot=\"change-review-list-item\"\n              data-change={entry.tone}\n              className=\"flex min-w-0 items-start gap-[var(--hui-space-2)]\"\n            >\n              <Icon aria-hidden=\"true\" className={cn(\"mt-0.5 size-3.5 shrink-0\", entryTone[entry.tone])} />\n              <span className=\"sr-only\">{labels[entry.tone]}: </span>\n              <span className={cn(\"min-w-0 break-words\", entry.tone === \"removed\" && \"line-through\")}>\n                {itemLabel ? itemLabel(entry.item) : String(entry.item)}\n              </span>\n            </li>\n          )\n        })}\n      </ul>\n      {overflow > 0 && (\n        <Button\n          variant=\"link\"\n          size=\"sm\"\n          aria-expanded={expanded}\n          aria-controls={listId}\n          className=\"-ml-[var(--hui-space-3)] self-start\"\n          onClick={() => setExpanded((current) => !current)}\n        >\n          {expanded ? labels.showFewer : labels.showMore(overflow)}\n        </Button>\n      )}\n    </div>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Decisions                                                                  */\n/* -------------------------------------------------------------------------- */\n\n// The tinted fills are light, so the label stays in the primary text colour to keep its contrast.\nconst decisionTone: Record<ChangeReviewDecision, string> = {\n  accepted:\n    \"border-[var(--hui-color-border-success-emphasis)] bg-[var(--hui-color-background-success-primary)] hover:bg-[var(--hui-color-background-success-primary)]\",\n  rejected:\n    \"border-[var(--hui-color-border-danger-emphasis)] bg-[var(--hui-color-background-danger-primary)] hover:bg-[var(--hui-color-background-danger-primary)]\",\n}\n\n/**\n * Two toggle buttons. Pressing the chosen one again returns the change to\n * undecided. The chosen button gains an icon as well as a fill, so the choice\n * does not rest on color.\n */\nfunction DecisionControls({ change }: { change: ChangeReviewChange }) {\n  const { decisions, decide, disabled, labels } = useChangeReviewController(\"ChangeReviewDecision\")\n  const current = decisions[change.id]\n\n  const toggle = (decision: ChangeReviewDecision) => (\n    <Button\n      variant=\"ghost\"\n      size=\"sm\"\n      disabled={disabled}\n      aria-pressed={current === decision}\n      aria-label={\n        decision === \"accepted\" ? labels.acceptChange(change.label) : labels.rejectChange(change.label)\n      }\n      data-decision={decision}\n      className={cn(\"border-solid\", current === decision && decisionTone[decision])}\n      onClick={() => decide(change.id, current === decision ? null : decision)}\n    >\n      {current === decision &&\n        (decision === \"accepted\" ? (\n          <CheckIcon aria-hidden=\"true\" className=\"size-3.5\" />\n        ) : (\n          <CloseIcon aria-hidden=\"true\" className=\"size-3.5\" />\n        ))}\n      {decision === \"accepted\" ? labels.accept : labels.reject}\n    </Button>\n  )\n\n  return (\n    <div data-slot=\"change-review-decision\" className=\"flex items-center gap-[var(--hui-space-2)]\">\n      {toggle(\"rejected\")}\n      {toggle(\"accepted\")}\n    </div>\n  )\n}\n\nexport {\n  ChangeReview,\n  ChangeReviewBulkActions,\n  ChangeReviewFilter,\n  ChangeReviewHeader,\n  ChangeReviewList,\n  ChangeReviewSummary,\n}\n"},{"path":"change-review-context.tsx","type":"registry:component","target":"components/ui/change-review/change-review-context.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\n\nimport type { ChangeReviewControllerValue } from \"./change-review-types\"\n\nexport const ChangeReviewControllerContext =\n  React.createContext<ChangeReviewControllerValue | null>(null)\n\nChangeReviewControllerContext.displayName = \"ChangeReviewControllerContext\"\n\nexport function useChangeReviewController(componentName: string): ChangeReviewControllerValue {\n  const controller = React.useContext(ChangeReviewControllerContext)\n\n  if (!controller) {\n    throw new Error(\n      `<${componentName}> must be rendered inside <ChangeReview> so it can read the changes.`\n    )\n  }\n\n  return controller\n}\n"},{"path":"change-review-types.ts","type":"registry:component","target":"components/ui/change-review/change-review-types.ts","content":"import type * as React from \"react\"\n\nexport type ChangeReviewStatus = \"added\" | \"removed\" | \"changed\" | \"unchanged\"\n\nexport type ChangeReviewDecision = \"accepted\" | \"rejected\"\n\n/** Decisions keyed by change id. A change without an entry is undecided. */\nexport type ChangeReviewDecisions = Record<string, ChangeReviewDecision>\n\ntype ChangeReviewFieldBase = {\n  /** Dot path into both objects, such as `billing.email`. Also the change id. */\n  key: string\n  label: string\n}\n\nexport type ChangeReviewValueField = ChangeReviewFieldBase & {\n  type?: \"value\" | \"boolean\"\n  /** Display for one side of the change. Receives the raw value. */\n  render?: ((value: unknown) => React.ReactNode) | undefined\n  /** Replaces the structural comparison, for values such as trimmed text. */\n  isEqual?: ((before: unknown, after: unknown) => boolean) | undefined\n}\n\nexport type ChangeReviewListField = ChangeReviewFieldBase & {\n  type: \"list\"\n  /** Property name or function that identifies an item across both lists. */\n  itemKey?: string | ((item: never) => string) | undefined\n  itemLabel?: ((item: never) => React.ReactNode) | undefined\n}\n\nexport type ChangeReviewField = ChangeReviewValueField | ChangeReviewListField\n\nexport type ChangeReviewFieldGroup = {\n  group: string\n  fields: ChangeReviewFieldNode[]\n}\n\nexport type ChangeReviewFieldNode = ChangeReviewField | ChangeReviewFieldGroup\n\ntype ChangeReviewChangeBase = {\n  id: string\n  label: string\n  /** Group labels from the outermost inward. */\n  groups?: string[] | undefined\n  /** Set when the comparison is already known; derived from the values otherwise. */\n  status?: ChangeReviewStatus | undefined\n}\n\nexport type ChangeReviewValueChange = ChangeReviewChangeBase & {\n  kind?: \"value\" | undefined\n  type?: \"value\" | \"boolean\" | undefined\n  before?: unknown\n  after?: unknown\n  render?: ((value: unknown) => React.ReactNode) | undefined\n}\n\nexport type ChangeReviewListChange = ChangeReviewChangeBase & {\n  kind: \"list\"\n  added: unknown[]\n  removed: unknown[]\n  /** Items present on both sides whose contents differ. */\n  changed?: unknown[] | undefined\n  itemLabel?: ((item: never) => React.ReactNode) | undefined\n}\n\nexport type ChangeReviewChange = ChangeReviewValueChange | ChangeReviewListChange\n\nexport type ChangeReviewGroupNode = {\n  label: string\n  /** Stable within one review; built from the group path. */\n  id: string\n  changes: ChangeReviewChange[]\n  groups: ChangeReviewGroupNode[]\n}\n\nexport type ChangeReviewCounts = {\n  total: number\n  changed: number\n  accepted: number\n  rejected: number\n  undecided: number\n}\n\nexport type ChangeReviewLabels = {\n  notSet: string\n  enabled: string\n  disabled: string\n  noChange: string\n  /** Read before the old value by screen readers. */\n  was: string\n  /** Read before the new value by screen readers. */\n  now: string\n  added: string\n  removed: string\n  changed: string\n  changedOnly: string\n  accept: string\n  reject: string\n  acceptAll: string\n  rejectAll: string\n  empty: string\n  emptyFiltered: string\n  summary: (counts: { changed: number; total: number }) => string\n  decisionSummary: (counts: { accepted: number; rejected: number; undecided: number }) => string\n  listSummary: (counts: { added: number; removed: number; changed: number }) => string\n  acceptChange: (label: string) => string\n  rejectChange: (label: string) => string\n  showMore: (count: number) => string\n  showFewer: string\n}\n\nexport type ChangeReviewControllerValue = {\n  changes: ChangeReviewChange[]\n  visibleChanges: ChangeReviewChange[]\n  counts: ChangeReviewCounts\n  /** True when some fields did not change, so the filter has something to hide. */\n  hasUnchanged: boolean\n  changedOnly: boolean\n  setChangedOnly: (changedOnly: boolean) => void\n  /** True when the application handles decisions, so the controls are shown. */\n  canDecide: boolean\n  disabled: boolean\n  decisions: ChangeReviewDecisions\n  /** Pass `null` to return a change to undecided. */\n  decide: (id: string, decision: ChangeReviewDecision | null) => void\n  decideAll: (decision: ChangeReviewDecision) => void\n  labels: ChangeReviewLabels\n  renderValue: ((value: unknown, change: ChangeReviewValueChange) => React.ReactNode) | undefined\n  maxListItems: number\n}\n"},{"path":"change-review-utils.ts","type":"registry:component","target":"components/ui/change-review/change-review-utils.ts","content":"import type {\n  ChangeReviewChange,\n  ChangeReviewCounts,\n  ChangeReviewDecision,\n  ChangeReviewDecisions,\n  ChangeReviewFieldNode,\n  ChangeReviewGroupNode,\n  ChangeReviewLabels,\n  ChangeReviewListChange,\n  ChangeReviewListField,\n  ChangeReviewStatus,\n} from \"./change-review-types\"\n\ntype PlainObject = Record<string, unknown>\n\nfunction isPlainObject(value: unknown): value is PlainObject {\n  if (value === null || typeof value !== \"object\") return false\n  const prototype = Object.getPrototypeOf(value)\n  return prototype === Object.prototype || prototype === null\n}\n\n/** Reads a dot path such as `billing.email`; missing steps read as undefined. */\nexport function getValueAtPath(source: unknown, path: string): unknown {\n  let current = source\n  for (const segment of path.split(\".\")) {\n    if (current === null || current === undefined) return undefined\n    current = (current as PlainObject)[segment]\n  }\n  return current\n}\n\n/** Returns a copy of `source` with `path` set, sharing every untouched branch. */\nexport function setValueAtPath<T>(source: T, path: string, value: unknown): T {\n  const [head, ...rest] = path.split(\".\")\n  const base: PlainObject = isPlainObject(source) ? { ...source } : {}\n  base[head] = rest.length === 0 ? value : setValueAtPath(base[head], rest.join(\".\"), value)\n  return base as T\n}\n\n/** A field with no value on one side reads as added or removed, not changed. */\nexport function isEmptyValue(value: unknown): boolean {\n  return value === null || value === undefined || value === \"\"\n}\n\nexport function isDeepEqual(a: unknown, b: unknown): boolean {\n  if (Object.is(a, b)) return true\n  if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()\n  if (Array.isArray(a) && Array.isArray(b)) {\n    return a.length === b.length && a.every((item, index) => isDeepEqual(item, b[index]))\n  }\n  if (isPlainObject(a) && isPlainObject(b)) {\n    const keys = Object.keys(a)\n    return (\n      keys.length === Object.keys(b).length &&\n      keys.every((key) => Object.hasOwn(b, key) && isDeepEqual(a[key], b[key]))\n    )\n  }\n  return false\n}\n\nfunction toList(value: unknown): unknown[] {\n  if (Array.isArray(value)) return value\n  return isEmptyValue(value) ? [] : [value]\n}\n\nfunction listItemKey(item: unknown, itemKey: ChangeReviewListField[\"itemKey\"]): string {\n  if (typeof itemKey === \"function\") return itemKey(item as never)\n  if (typeof itemKey === \"string\") return String((item as PlainObject | null)?.[itemKey])\n  return typeof item === \"string\" ? item : JSON.stringify(item)\n}\n\n/**\n * Matches items by key. An item on both sides whose contents differ is\n * reported as changed, so equal membership is never mistaken for no change.\n */\nexport function compareLists(\n  before: unknown,\n  after: unknown,\n  itemKey?: ChangeReviewListField[\"itemKey\"]\n): Pick<ChangeReviewListChange, \"added\" | \"removed\" | \"changed\"> {\n  const beforeByKey = new Map(toList(before).map((item) => [listItemKey(item, itemKey), item]))\n  const afterKeys = new Set<string>()\n  const added: unknown[] = []\n  const changed: unknown[] = []\n\n  for (const item of toList(after)) {\n    const key = listItemKey(item, itemKey)\n    afterKeys.add(key)\n    if (!beforeByKey.has(key)) added.push(item)\n    else if (!isDeepEqual(beforeByKey.get(key), item)) changed.push(item)\n  }\n\n  const removed = [...beforeByKey].filter(([key]) => !afterKeys.has(key)).map(([, item]) => item)\n  return { added, removed, changed }\n}\n\nexport function getChangeStatus(change: ChangeReviewChange): ChangeReviewStatus {\n  if (change.status) return change.status\n\n  if (change.kind === \"list\") {\n    const size = change.added.length + change.removed.length + (change.changed?.length ?? 0)\n    return size === 0 ? \"unchanged\" : \"changed\"\n  }\n\n  if (isDeepEqual(change.before, change.after)) return \"unchanged\"\n  const hadValue = !isEmptyValue(change.before)\n  const hasValue = !isEmptyValue(change.after)\n  if (!hadValue && !hasValue) return \"unchanged\"\n  if (!hadValue) return \"added\"\n  if (!hasValue) return \"removed\"\n  return \"changed\"\n}\n\n/** Compares two versions of an object, one entry per field, in field order. */\nexport function getChanges(\n  before: unknown,\n  after: unknown,\n  fields: ChangeReviewFieldNode[],\n  groups: string[] = []\n): ChangeReviewChange[] {\n  return fields.flatMap((field): ChangeReviewChange[] => {\n    if (\"group\" in field) {\n      return getChanges(before, after, field.fields, [...groups, field.group])\n    }\n\n    const base = { id: field.key, label: field.label, groups: groups.length ? groups : undefined }\n    const previous = getValueAtPath(before, field.key)\n    const next = getValueAtPath(after, field.key)\n\n    if (field.type === \"list\") {\n      return [\n        {\n          ...base,\n          kind: \"list\",\n          ...compareLists(previous, next, field.itemKey),\n          itemLabel: field.itemLabel,\n        },\n      ]\n    }\n\n    const change: ChangeReviewChange = {\n      ...base,\n      type: field.type,\n      before: previous,\n      after: next,\n      render: field.render,\n    }\n    // A custom comparison can call two different raw values the same.\n    if (field.isEqual?.(previous, next)) change.status = \"unchanged\"\n    return [change]\n  })\n}\n\nexport function countChanges(\n  changes: ChangeReviewChange[],\n  decisions: ChangeReviewDecisions\n): ChangeReviewCounts {\n  const counts: ChangeReviewCounts = {\n    total: changes.length,\n    changed: 0,\n    accepted: 0,\n    rejected: 0,\n    undecided: 0,\n  }\n\n  for (const change of changes) {\n    if (getChangeStatus(change) === \"unchanged\") continue\n    counts.changed += 1\n    const decision = decisions[change.id]\n    if (decision === \"accepted\") counts.accepted += 1\n    else if (decision === \"rejected\") counts.rejected += 1\n    else counts.undecided += 1\n  }\n\n  return counts\n}\n\n/** Sets one decision on every change that actually changed. */\nexport function decideAllChanges(\n  changes: ChangeReviewChange[],\n  decision: ChangeReviewDecision\n): ChangeReviewDecisions {\n  const decisions: ChangeReviewDecisions = {}\n  for (const change of changes) {\n    if (getChangeStatus(change) !== \"unchanged\") decisions[change.id] = decision\n  }\n  return decisions\n}\n\n/**\n * Builds the object an approval produces: `before`, with each accepted field\n * taken from `after`. Rejected and undecided fields keep their `before` value.\n * Decision ids are field keys, so this applies to reviews built from `fields`.\n */\nexport function applyAcceptedChanges<T>(before: T, after: T, decisions: ChangeReviewDecisions): T {\n  let result = before\n  for (const [key, decision] of Object.entries(decisions)) {\n    if (decision === \"accepted\") {\n      result = setValueAtPath(result, key, getValueAtPath(after, key))\n    }\n  }\n  return result\n}\n\n/** Nests changes under their group labels, keeping the order they arrived in. */\nexport function groupChanges(changes: ChangeReviewChange[]): ChangeReviewGroupNode {\n  const root: ChangeReviewGroupNode = { label: \"\", id: \"root\", changes: [], groups: [] }\n\n  for (const change of changes) {\n    let node = root\n    for (const label of change.groups ?? []) {\n      let child = node.groups.find((group) => group.label === label)\n      if (!child) {\n        child = { label, id: `${node.id}/${label}`, changes: [], groups: [] }\n        node.groups.push(child)\n      }\n      node = child\n    }\n    node.changes.push(change)\n  }\n\n  return root\n}\n\nfunction plural(count: number, singular: string, pluralForm: string) {\n  return `${count} ${count === 1 ? singular : pluralForm}`\n}\n\nexport const defaultChangeReviewLabels: ChangeReviewLabels = {\n  notSet: \"Not set\",\n  enabled: \"Enabled\",\n  disabled: \"Disabled\",\n  noChange: \"No change\",\n  was: \"Was\",\n  now: \"now\",\n  added: \"Added\",\n  removed: \"Removed\",\n  changed: \"Changed\",\n  changedOnly: \"Changed fields only\",\n  accept: \"Accept\",\n  reject: \"Reject\",\n  acceptAll: \"Accept all\",\n  rejectAll: \"Reject all\",\n  empty: \"There are no fields to review.\",\n  emptyFiltered: \"Nothing changed.\",\n  summary: ({ changed, total }) =>\n    changed === total\n      ? plural(changed, \"change\", \"changes\")\n      : `${plural(changed, \"change\", \"changes\")} in ${plural(total, \"field\", \"fields\")}`,\n  decisionSummary: ({ accepted, rejected, undecided }) =>\n    `${accepted} accepted, ${rejected} rejected, ${undecided} undecided`,\n  listSummary: ({ added, removed, changed }) =>\n    [\n      added ? `${added} added` : \"\",\n      removed ? `${removed} removed` : \"\",\n      changed ? `${changed} changed` : \"\",\n    ]\n      .filter(Boolean)\n      .join(\", \"),\n  acceptChange: (label) => `Accept change to ${label}`,\n  rejectChange: (label) => `Reject change to ${label}`,\n  showMore: (count) => `Show ${count} more`,\n  showFewer: \"Show fewer\",\n}\n"}],"dependencies":["honestui"],"registryDependencies":["https://www.honestui.com/r/button.json","https://www.honestui.com/r/switch.json"]}