{"name":"async-action","type":"registry:component","files":[{"path":"async-action.tsx","type":"registry:component","target":"components/ui/async-action/async-action.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport {\n  CircleAlert as CircleAlertIcon,\n  CircleCheck as CircleCheckIcon,\n  LoaderCircle as LoaderCircleIcon,\n  X as CloseIcon,\n} from \"honestui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\n\nimport type {\n  ActionStatusLabels,\n  AsyncAction,\n  AsyncActionHandle,\n  AsyncActionPhase,\n} from \"./async-action-types\"\nimport { defaultActionStatusLabels, getActionStatusMessage } from \"./async-action-utils\"\n\nexport type {\n  ActionStatusLabels,\n  AsyncAction,\n  AsyncActionCapabilities,\n  AsyncActionHandle,\n  AsyncActionOptions,\n  AsyncActionOutcome,\n  AsyncActionPhase,\n  AsyncActionState,\n} from \"./async-action-types\"\nexport { defaultActionStatusLabels } from \"./async-action-utils\"\nexport { useAsyncAction } from \"./use-async-action\"\n\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\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/* -------------------------------------------------------------------------- */\n/* Button                                                                     */\n/* -------------------------------------------------------------------------- */\n\ntype ActionButtonInput<TInput> = undefined extends TInput\n  ? { /** Passed to `run`. */ input?: TInput }\n  : { /** Passed to `run`. */ input: TInput }\n\nexport type ActionButtonProps<TInput = void> = React.ComponentProps<typeof Button> &\n  ActionButtonInput<TInput> & {\n    action: Pick<AsyncAction<TInput, unknown>, \"state\" | \"isBusy\" | \"run\">\n    /** Replaces the children while this button's own run is pending. */\n    pendingLabel?: React.ReactNode\n  }\n\n/**\n * Starts the action. While any run is in flight it stays focusable but\n * inactive, so keyboard focus is not thrown back to the page.\n */\nfunction ActionButton<TInput = void>(props: ActionButtonProps<TInput>) {\n  const { action, input, pendingLabel, className, children, onClick, ...buttonProps } =\n    props as React.ComponentProps<typeof Button> & {\n      action: ActionButtonProps<TInput>[\"action\"]\n      input?: TInput\n      pendingLabel?: React.ReactNode\n    }\n  const { state } = action\n  // One hook can serve several buttons; only the one that started the run shows progress.\n  const isPending = state.phase === \"pending\" && Object.is(state.input, input)\n\n  return (\n    <Button\n      data-slot=\"action-button\"\n      data-pending={isPending ? \"\" : undefined}\n      aria-disabled={action.isBusy || undefined}\n      className={cn(\n        forcedColorsBorder,\n        \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:active:scale-100!\",\n        className\n      )}\n      onClick={(event) => {\n        onClick?.(event)\n        if (event.defaultPrevented || action.isBusy) return\n        void action.run(input as TInput)\n      }}\n      {...buttonProps}\n    >\n      {isPending && (\n        <LoaderCircleIcon aria-hidden=\"true\" className=\"size-3.5 motion-safe:animate-spin\" />\n      )}\n      {isPending && pendingLabel !== undefined ? pendingLabel : children}\n    </Button>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Status                                                                     */\n/* -------------------------------------------------------------------------- */\n\nconst phaseIcon: Partial<Record<AsyncActionPhase, typeof CircleCheckIcon>> = {\n  pending: LoaderCircleIcon,\n  undoing: LoaderCircleIcon,\n  success: CircleCheckIcon,\n  undone: CircleCheckIcon,\n  error: CircleAlertIcon,\n  undoError: CircleAlertIcon,\n}\n\nconst phaseTone: Partial<Record<AsyncActionPhase, string>> = {\n  pending: \"text-[var(--hui-color-foreground-base-secondary)] motion-safe:animate-spin\",\n  undoing: \"text-[var(--hui-color-foreground-base-secondary)] motion-safe:animate-spin\",\n  success: \"text-[var(--hui-color-foreground-success-primary)]\",\n  undone: \"text-[var(--hui-color-foreground-success-primary)]\",\n  error: \"text-[var(--hui-color-foreground-danger-primary)]\",\n  undoError: \"text-[var(--hui-color-foreground-danger-primary)]\",\n}\n\nexport type ActionStatusProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"ref\"> & {\n  action: AsyncActionHandle\n  /** Replacement wording. Write the outcome messages for your action. */\n  labels?: Partial<ActionStatusLabels> | undefined\n}\n\n/**\n * States the phase in words and offers the recovery that applies to it:\n * Cancel while pending, Retry after a failure, Undo after success. Keep it\n * mounted; it is an empty live region while the action is idle, which is what\n * lets screen readers announce the first message.\n */\nfunction ActionStatus({ action, labels: labelsProp, className, ...props }: ActionStatusProps) {\n  const { state } = action\n  const labels = React.useMemo(\n    () => ({ ...defaultActionStatusLabels, ...labelsProp }),\n    [labelsProp]\n  )\n  const message = getActionStatusMessage(state, labels)\n  const Icon = phaseIcon[state.phase]\n\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const lastFocused = React.useRef<Element | null>(null)\n\n  React.useEffect(() => {\n    lastFocused.current = document.activeElement\n    const remember = (event: FocusEvent) => {\n      lastFocused.current = event.target instanceof Element ? event.target : null\n    }\n    document.addEventListener(\"focusin\", remember)\n    return () => document.removeEventListener(\"focusin\", remember)\n  }, [])\n\n  // A phase change can remove the focused control: a used Retry button, or the\n  // row an optimistic update took away. Hold focus on the status so it does\n  // not fall back to the top of the page.\n  React.useEffect(() => {\n    const root = rootRef.current\n    const previous = lastFocused.current\n    if (!root || !previous || previous === document.body || previous.isConnected) return\n    if (document.activeElement && document.activeElement !== document.body) return\n    root.focus()\n  }, [state.phase])\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"action-status\"\n      data-phase={state.phase}\n      tabIndex={-1}\n      className={cn(\n        \"flex min-w-0 flex-wrap items-center gap-x-[var(--hui-space-3)] gap-y-[var(--hui-space-2)] rounded-[var(--hui-radius-1)] text-[var(--hui-color-foreground-base-primary)] outline-none focus-visible:[outline:var(--hui-focus-ring)]\",\n        bodyText,\n        className\n      )}\n      {...props}\n    >\n      <p\n        role=\"status\"\n        data-slot=\"action-status-message\"\n        className=\"m-0 flex min-w-0 items-start gap-[var(--hui-space-2)]\"\n      >\n        {Icon && (\n          <Icon aria-hidden=\"true\" className={cn(\"mt-0.5 size-3.5 shrink-0\", phaseTone[state.phase])} />\n        )}\n        {message !== null && <span className=\"min-w-0 break-words\">{message}</span>}\n      </p>\n      {/* Outside the status region, so the buttons are not read out as part of the message. */}\n      {(action.canCancel || action.canRetry || action.canUndo || action.canDismiss) && (\n        <div\n          data-slot=\"action-status-actions\"\n          className=\"flex items-center gap-[var(--hui-space-2)]\"\n        >\n          {action.canCancel && (\n            <Button\n              variant=\"secondary\"\n              size=\"sm\"\n              className={forcedColorsBorder}\n              onClick={action.cancel}\n            >\n              {labels.cancel}\n            </Button>\n          )}\n          {action.canRetry && (\n            <Button\n              variant=\"secondary\"\n              size=\"sm\"\n              className={forcedColorsBorder}\n              onClick={action.retry}\n            >\n              {labels.retry}\n            </Button>\n          )}\n          {action.canUndo && (\n            <Button\n              variant=\"secondary\"\n              size=\"sm\"\n              className={forcedColorsBorder}\n              onClick={action.undo}\n            >\n              {labels.undo}\n            </Button>\n          )}\n          {action.canDismiss && (\n            <Button\n              variant=\"link\"\n              size=\"icon-sm\"\n              aria-label={labels.dismiss}\n              onClick={action.reset}\n            >\n              <CloseIcon aria-hidden=\"true\" />\n            </Button>\n          )}\n        </div>\n      )}\n    </div>\n  )\n}\n\nexport { ActionButton, ActionStatus }\n"},{"path":"async-action-types.ts","type":"registry:component","target":"components/ui/async-action/async-action-types.ts","content":"export type AsyncActionPhase =\n  | \"idle\"\n  | \"pending\"\n  | \"success\"\n  | \"error\"\n  | \"cancelled\"\n  | \"undoing\"\n  | \"undone\"\n  | \"undoError\"\n\n/** Each phase carries only what is known in it, so a result cannot exist before success. */\nexport type AsyncActionState<TInput, TResult> =\n  | { phase: \"idle\" }\n  | { phase: \"pending\"; input: TInput }\n  | { phase: \"success\"; input: TInput; result: TResult }\n  | { phase: \"error\"; input: TInput; error: unknown }\n  | { phase: \"cancelled\"; input: TInput }\n  | { phase: \"undoing\"; input: TInput; result: TResult }\n  | { phase: \"undone\"; input: TInput; result: TResult }\n  | { phase: \"undoError\"; input: TInput; result: TResult; error: unknown }\n\nexport type AsyncActionOptions<TInput, TResult> = {\n  /** The request. `signal` aborts when the run is cancelled. */\n  action: (input: TInput, context: { signal: AbortSignal }) => Promise<TResult>\n  /**\n   * Shows the expected result at once and returns the function that takes it\n   * back. The rollback runs when the request fails, is cancelled, or is undone.\n   */\n  optimistic?: ((input: TInput) => (() => void) | void) | undefined\n  /** A real reversal of the action. Undo is offered only when this is passed. */\n  undo?: ((input: TInput, context: { result: TResult }) => Promise<unknown>) | undefined\n  /** Set only when aborting `signal` really stops the request. Offers Cancel while pending. */\n  cancelable?: boolean | undefined\n  onSuccess?: ((result: TResult, input: TInput) => void) | undefined\n  onError?: ((error: unknown, input: TInput) => void) | undefined\n}\n\n/** How one call to `run` ended. `ignored` means another run was already in progress. */\nexport type AsyncActionOutcome<TResult> =\n  | { status: \"success\"; result: TResult }\n  | { status: \"error\"; error: unknown }\n  | { status: \"cancelled\" }\n  | { status: \"ignored\" }\n\nexport type AsyncActionCapabilities = {\n  /** A request or an undo is in flight, so new runs are ignored. */\n  isBusy: boolean\n  canCancel: boolean\n  canRetry: boolean\n  canUndo: boolean\n  canDismiss: boolean\n}\n\nexport type AsyncActionStore<TInput, TResult> = {\n  getState: () => AsyncActionState<TInput, TResult>\n  subscribe: (listener: () => void) => () => void\n  /** Replaces the request and callbacks used from the next step onward. */\n  setOptions: (options: AsyncActionOptions<TInput, TResult>) => void\n  run: (input: TInput) => Promise<AsyncActionOutcome<TResult>>\n  /** Repeats the failed request, or the failed undo, with the same input. */\n  retry: () => void\n  cancel: () => void\n  undo: () => void\n  /** Returns to idle. Does nothing while a request or an undo is in flight. */\n  reset: () => void\n}\n\nexport type AsyncAction<TInput, TResult> = AsyncActionCapabilities &\n  Pick<AsyncActionStore<TInput, TResult>, \"run\" | \"retry\" | \"cancel\" | \"undo\" | \"reset\"> & {\n    state: AsyncActionState<TInput, TResult>\n  }\n\n/** What `ActionStatus` reads. Omits `run`, so an action with any input type fits. */\nexport type AsyncActionHandle = Omit<AsyncAction<unknown, unknown>, \"run\">\n\nexport type ActionStatusLabels = {\n  pending: string\n  success: string\n  error: string | ((error: unknown) => string)\n  cancelled: string\n  undoing: string\n  undone: string\n  undoError: string | ((error: unknown) => string)\n  retry: string\n  cancel: string\n  undo: string\n  dismiss: string\n}\n"},{"path":"async-action-utils.ts","type":"registry:component","target":"components/ui/async-action/async-action-utils.ts","content":"import type {\n  ActionStatusLabels,\n  AsyncActionCapabilities,\n  AsyncActionOptions,\n  AsyncActionOutcome,\n  AsyncActionState,\n  AsyncActionStore,\n} from \"./async-action-types\"\n\nexport const defaultActionStatusLabels: ActionStatusLabels = {\n  pending: \"Working…\",\n  success: \"Done.\",\n  error: \"This did not finish. Try again.\",\n  cancelled: \"Cancelled.\",\n  undoing: \"Undoing…\",\n  undone: \"Undone.\",\n  undoError: \"This could not be undone. Try again.\",\n  retry: \"Retry\",\n  cancel: \"Cancel\",\n  undo: \"Undo\",\n  dismiss: \"Dismiss\",\n}\n\n/** `supports` says what the developer supplied: a reversal, and a request that can be aborted. */\nexport function getAsyncActionCapabilities(\n  state: AsyncActionState<unknown, unknown>,\n  supports: { undo: boolean; cancel: boolean }\n): AsyncActionCapabilities {\n  const isBusy = state.phase === \"pending\" || state.phase === \"undoing\"\n\n  return {\n    isBusy,\n    canCancel: state.phase === \"pending\" && supports.cancel,\n    canRetry: state.phase === \"error\" || state.phase === \"undoError\",\n    canUndo: state.phase === \"success\" && supports.undo,\n    canDismiss: state.phase !== \"idle\" && !isBusy,\n  }\n}\n\n/** The sentence for the current phase, or `null` when there is nothing to say. */\nexport function getActionStatusMessage(\n  state: AsyncActionState<unknown, unknown>,\n  labels: ActionStatusLabels\n): string | null {\n  if (state.phase === \"idle\") return null\n  if (state.phase === \"error\" || state.phase === \"undoError\") {\n    const label = labels[state.phase]\n    return typeof label === \"function\" ? label(state.error) : label\n  }\n  return labels[state.phase]\n}\n\n/**\n * Coordinates one action outside React, so a second click in the same tick\n * already sees the pending state. Options are read at each step, so\n * `setOptions` can replace them with fresh closures at any time.\n */\nexport function createAsyncActionStore<TInput = void, TResult = unknown>(\n  initialOptions: AsyncActionOptions<TInput, TResult>\n): AsyncActionStore<TInput, TResult> {\n  let options = initialOptions\n  let state: AsyncActionState<TInput, TResult> = { phase: \"idle\" }\n  // Cancelling moves this on, so the cancelled request's late settle is recognised and dropped.\n  let currentRun = 0\n  let abortController: AbortController | null = null\n  let rollback: (() => void) | null = null\n  const listeners = new Set<() => void>()\n\n  function setState(next: AsyncActionState<TInput, TResult>) {\n    state = next\n    for (const listener of listeners) listener()\n  }\n\n  function rollBack() {\n    const takeBack = rollback\n    rollback = null\n    takeBack?.()\n  }\n\n  function isBusy() {\n    return state.phase === \"pending\" || state.phase === \"undoing\"\n  }\n\n  async function run(input: TInput): Promise<AsyncActionOutcome<TResult>> {\n    if (isBusy()) return { status: \"ignored\" }\n\n    currentRun += 1\n    const runId = currentRun\n    const controller = new AbortController()\n    abortController = controller\n    rollback = null\n    setState({ phase: \"pending\", input })\n\n    let settled: { ok: true; result: TResult } | { ok: false; error: unknown }\n    try {\n      rollback = options.optimistic?.(input) ?? null\n      settled = { ok: true, result: await options.action(input, { signal: controller.signal }) }\n    } catch (error) {\n      settled = { ok: false, error }\n    }\n\n    if (runId !== currentRun) return { status: \"cancelled\" }\n    abortController = null\n\n    if (!settled.ok) {\n      rollBack()\n      setState({ phase: \"error\", input, error: settled.error })\n      options.onError?.(settled.error, input)\n      return { status: \"error\", error: settled.error }\n    }\n\n    // The rollback is kept: a later undo uses it to restore the interface.\n    setState({ phase: \"success\", input, result: settled.result })\n    options.onSuccess?.(settled.result, input)\n    return { status: \"success\", result: settled.result }\n  }\n\n  async function undo() {\n    if (state.phase !== \"success\" && state.phase !== \"undoError\") return\n    const reverse = options.undo\n    if (!reverse) return\n\n    const { input, result } = state\n    setState({ phase: \"undoing\", input, result })\n\n    try {\n      await reverse(input, { result })\n    } catch (error) {\n      // The action still stands, so the interface keeps showing its result.\n      setState({ phase: \"undoError\", input, result, error })\n      return\n    }\n\n    rollBack()\n    setState({ phase: \"undone\", input, result })\n  }\n\n  return {\n    getState: () => state,\n    setOptions(next) {\n      options = next\n    },\n    subscribe(listener) {\n      listeners.add(listener)\n      return () => {\n        listeners.delete(listener)\n      }\n    },\n    run,\n    undo: () => void undo(),\n    retry() {\n      if (state.phase === \"error\") void run(state.input)\n      else if (state.phase === \"undoError\") void undo()\n    },\n    cancel() {\n      if (state.phase !== \"pending\" || options.cancelable !== true) return\n      const { input } = state\n      currentRun += 1\n      abortController?.abort()\n      abortController = null\n      rollBack()\n      setState({ phase: \"cancelled\", input })\n    },\n    reset() {\n      if (state.phase === \"idle\" || isBusy()) return\n      rollback = null\n      setState({ phase: \"idle\" })\n    },\n  }\n}\n"},{"path":"use-async-action.ts","type":"registry:component","target":"components/ui/async-action/use-async-action.ts","content":"\"use client\"\n\nimport * as React from \"react\"\n\nimport type { AsyncAction, AsyncActionOptions } from \"./async-action-types\"\nimport { createAsyncActionStore, getAsyncActionCapabilities } from \"./async-action-utils\"\n\n/**\n * Runs one action at a time and keeps its outcome until it is dismissed or run\n * again. Call it above anything the optimistic update removes, so the state\n * outlives the row or card that started the action.\n */\nexport function useAsyncAction<TInput = void, TResult = unknown>(\n  options: AsyncActionOptions<TInput, TResult>\n): AsyncAction<TInput, TResult> {\n  const [store] = React.useState(() => createAsyncActionStore(options))\n  // Options are usually new closures on each render; the store reads the latest when it acts.\n  React.useEffect(() => {\n    store.setOptions(options)\n  })\n  const state = React.useSyncExternalStore(store.subscribe, store.getState, store.getState)\n\n  const supportsUndo = options.undo !== undefined\n  const supportsCancel = options.cancelable === true\n\n  return React.useMemo(\n    () => ({\n      state,\n      ...getAsyncActionCapabilities(state, { undo: supportsUndo, cancel: supportsCancel }),\n      run: store.run,\n      retry: store.retry,\n      cancel: store.cancel,\n      undo: store.undo,\n      reset: store.reset,\n    }),\n    [state, store, supportsUndo, supportsCancel]\n  )\n}\n"}],"dependencies":["honestui"],"registryDependencies":["https://www.honestui.com/r/button.json"]}