{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"button","title":"Button","description":"Action family with variants, sizes, loading behavior, single-line icon labels, and accessible disabled states.","dependencies":["@base-ui/react@1.7.0","class-variance-authority@0.7.1"],"registryDependencies":["@siteplane/base","@siteplane/spinner","@siteplane/badge"],"files":[{"path":"packages/ui/src/components/button/button.tsx","content":"\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport * as React from \"react\";\n\nimport { observeTruncation } from \"../../system/hooks/use-truncated\";\nimport {\n  siteplaneUIDefaults,\n  useResolvedControlSize,\n} from \"../../system/provider/siteplane-ui-provider\";\nimport {\n  formControlSurfaceClassNames,\n  surfaceClassNames,\n} from \"../../system/styles/surface\";\nimport { cn } from \"../../system/utils/cn\";\nimport { slotClass } from \"../../system/utils/slot-class\";\nimport { wrapTextRuns } from \"../../system/utils/text-runs\";\nimport { Spinner } from \"../spinner\";\nimport type {\n  KitButtonProfile,\n  KitButtonProfileMode,\n  KitButtonProfileTone,\n  SelectTriggerButtonMode,\n} from \"./button-internal\";\n\nimport \"./button.css\";\n\nexport const buttonVariants = cva(\n  \"pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n  {\n    defaultVariants: {\n      size: siteplaneUIDefaults.controlSize,\n      variant: \"default\",\n    },\n    variants: {\n      // The icon and label of each size come from the control scale in\n      // design-rework.css (--sp-rw-button-icon and the size's text); the\n      // ::before layer takes the chassis radius at every size.\n      size: {\n        md: \"\",\n        \"icon-md\": \"\",\n        \"icon-lg\": \"\",\n        \"icon-sm\": \"\",\n        \"icon-xl\": \"\",\n        \"icon-xs\": \"\",\n        \"icon-2xs\": \"\",\n        lg: \"\",\n        sm: \"\",\n        xl: \"\",\n        xs: \"\",\n      },\n      variant: {\n        default:\n          \"*:data-[slot=button-loading-indicator]:text-secondary-foreground\",\n        accent:\n          \"*:data-[slot=button-loading-indicator]:text-primary-foreground\",\n        destructive: \"*:data-[slot=button-loading-indicator]:text-white\",\n        \"frosted-accent\": \"backdrop-blur-md\",\n        // The shared glass surface draws the fill, edge, shadow, sheen, blur\n        // and text colour; the Button keeps its geometry, focus and\n        // interaction layer.\n        glass: \"\",\n        \"ghost-destructive\":\n          \"*:data-[slot=button-loading-indicator]:text-destructive-foreground\",\n        inverse:\n          \"*:data-[slot=button-loading-indicator]:text-primary-foreground\",\n        \"outline-destructive\":\n          \"*:data-[slot=button-loading-indicator]:text-foreground\",\n        ghost: \"*:data-[slot=button-loading-indicator]:text-foreground\",\n        // A link is underlined at rest; a text Button underlines on hover\n        // and press (design-rework.css). Lukas, 16 October 2026.\n        link: \"underline underline-offset-4 *:data-[slot=button-loading-indicator]:text-foreground\",\n        \"muted-accent\": \"\",\n        outline: \"*:data-[slot=button-loading-indicator]:text-foreground\",\n        text: \"not-aria-disabled:data-pressed:opacity-80 *:data-[slot=button-loading-indicator]:text-foreground\",\n      },\n    },\n  },\n);\n\nconst buttonVariantBindings = {\n  accent: { chassis: \"solid\", skin: \"accent\" },\n  default: { chassis: \"solid\", skin: \"neutral\" },\n  destructive: { chassis: \"solid\", skin: \"destructive\" },\n  \"frosted-accent\": { chassis: \"frosted\", skin: \"accent\" },\n  ghost: { chassis: \"ghost\", skin: \"neutral\" },\n  \"ghost-destructive\": { chassis: \"ghost\", skin: \"destructive\" },\n  glass: { chassis: \"frosted\", skin: \"neutral\" },\n  inverse: { chassis: \"solid\", skin: \"inverse\" },\n  link: { chassis: \"link\", skin: \"neutral\" },\n  \"muted-accent\": { chassis: \"muted\", skin: \"accent\" },\n  outline: { chassis: \"outline\", skin: \"neutral\" },\n  \"outline-destructive\": { chassis: \"outline\", skin: \"destructive\" },\n  text: { chassis: \"text\", skin: \"neutral\" },\n} as const;\n\nconst solidEffectLayers = [\n  \"drop-shadow\",\n  \"interaction\",\n  \"spotlights\",\n  \"dome-overlay\",\n  \"rim\",\n  \"secondary-spotlights\",\n] as const;\n\nconst buttonVariantEffectLayers = {\n  accent: solidEffectLayers,\n  default: solidEffectLayers,\n  destructive: solidEffectLayers,\n  \"frosted-accent\": [\n    \"drop-shadow\",\n    \"interaction\",\n    \"spotlights\",\n    \"dome-overlay\",\n    \"rim\",\n  ],\n  ghost: [\"interaction\"],\n  \"ghost-destructive\": [\"interaction\"],\n  glass: [\"interaction\"],\n  inverse: solidEffectLayers,\n  link: [],\n  \"muted-accent\": [\"interaction\"],\n  outline: [\"interaction\", \"rim\"],\n  \"outline-destructive\": [\"interaction\", \"rim\"],\n  text: [],\n} as const;\n\n// A profile can add a layer its variant does not render. The navigation-link\n// profile keeps the layerless text variant but draws the current destination's\n// persistent fill through the same interaction layer the Sidebar rows use.\nconst buttonProfileEffectLayers: Partial<\n  Record<KitButtonProfile, readonly string[]>\n> = {\n  \"navigation-link\": [\"interaction\"],\n};\n\nexport interface ButtonBaseProps extends useRender.ComponentProps<\"button\"> {\n  variant?: VariantProps<typeof buttonVariants>[\"variant\"];\n  size?: VariantProps<typeof buttonVariants>[\"size\"];\n  /**\n   * `square` is the icon-only box and inherits the matching square size.\n   * `pill` rounds the control fully at every size; the size keeps its height\n   * and padding, and the variant and its states keep their styling.\n   */\n  shape?: \"default\" | \"square\" | \"pill\";\n  loading?: boolean;\n  align?: \"center\" | \"start\" | \"between\";\n  multiline?: boolean;\n  width?: \"auto\" | \"fit\" | \"full\";\n}\n\n/**\n * The documented profiles. Kit components compose Button with further,\n * internal profiles (`button-internal.ts`); those are not public API.\n *\n * - `avatar` makes an Avatar child the trigger itself: circular interaction\n *   surface and focus ring, at the matching square icon size.\n * - `field-action` is the icon-only action inside a field: show or hide a\n *   secret, copy, clear, open a picker. It renders the bare glyph, muted at\n *   rest and primary on hover, press and open, with no fill; the keyboard\n *   focus outline hugs the glyph. A square one defaults to the 24px `icon-xs`\n *   target. A press keeps focus inside the field in every engine\n *   (`focusFieldActionOnPress`).\n * - `inline` is an action inside running text; it takes `profileTone`.\n * - `navigation-link` is a navigation trigger or destination.\n */\nexport type ButtonProfile =\n  \"default\" | \"avatar\" | \"field-action\" | \"inline\" | \"navigation-link\";\n\n/**\n * The tones of the `inline` profile. `muted` rests in the tertiary text\n * colour, for a secondary inline action. `accent` takes the accent text colour\n * at rest and on interaction, for a link that stands out beside neutral ones.\n * `destructive` rests like any link and takes the destructive text colour on\n * hover and press, for an action that removes something.\n */\nexport type ButtonProfileTone = \"muted\" | \"accent\" | \"destructive\";\n\nexport interface ButtonDefaultProfileProps extends ButtonBaseProps {\n  profile?: Exclude<ButtonProfile, \"inline\">;\n  profileTone?: never;\n}\n\nexport interface InlineButtonProps extends ButtonBaseProps {\n  profile: \"inline\";\n  profileTone?: ButtonProfileTone;\n}\n\nexport type ButtonStandardProps = ButtonDefaultProfileProps | InlineButtonProps;\n\nexport type ButtonProps = ButtonStandardProps;\n\n/** Every prop the implementation reads, internal profiles included. */\ntype ButtonImplementationProps = ButtonBaseProps & {\n  profile?: KitButtonProfile;\n  profileMode?: KitButtonProfileMode;\n  profileTone?: KitButtonProfileTone;\n};\n\nconst selectTriggerProfileModeClassNames = {\n  default: formControlSurfaceClassNames.default,\n  elevated: formControlSurfaceClassNames.elevated,\n  ghost: \"\",\n  inset: formControlSurfaceClassNames.inset,\n} as const satisfies Record<SelectTriggerButtonMode, string>;\n\n// `focusVisible` is not in TypeScript's FocusOptions yet; every current engine\n// reads it.\nconst fieldActionFocusOptions: FocusOptions & { focusVisible?: boolean } = {\n  focusVisible: false,\n  preventScroll: true,\n};\n\n/**\n * A pressed field action takes focus in every engine. Chromium and Firefox\n * focus a pressed button themselves; Safari does not, so focus fell from the\n * field's control to the page, and the field counted as left: a Field in\n * onBlur mode validated while the user was still in it. The press now moves\n * focus from the control straight to the action, without the keyboard ring. A\n * handler that keeps focus elsewhere prevents the default first and wins, as\n * Base UI's Combobox clear and trigger do to keep it in their input.\n */\nfunction focusFieldActionOnPress(\n  event: React.MouseEvent<HTMLButtonElement>,\n): void {\n  if (event.button !== 0 || event.defaultPrevented) return;\n  event.preventDefault();\n  event.currentTarget.focus(fieldActionFocusOptions);\n}\n\n/*\n * A label that ends with an ellipsis carries its full text as its title, and\n * a label that fits carries none, so a short label adds no native tooltip. The\n * kit's one truncation observer, which Truncate shares, reads the label's box\n * when its size changes, after the first layout too.\n */\nfunction syncLabelTitle(cut: boolean, label: Element): void {\n  if (cut) label.setAttribute(\"title\", label.textContent ?? \"\");\n  else label.removeAttribute(\"title\");\n}\n\n/**\n * The ref of a Button label: it keeps the label's title in step with its\n * ellipsis while the label is mounted. It is one stable function, so React\n * calls it once per label and runs the cleanup it returns on unmount.\n */\nfunction labelTitleRef(label: HTMLSpanElement | null): (() => void) | void {\n  if (!label) return;\n  return observeTruncation(label, syncLabelTitle);\n}\n\n// The render element's capture handlers are cleared with explicit `undefined`\n// values when it is cloned; the Button's own handlers call them instead.\ntype ButtonRenderCaptureProps = {\n  onClickCapture?: React.MouseEventHandler<HTMLElement> | undefined;\n  onKeyDownCapture?: React.KeyboardEventHandler<HTMLElement> | undefined;\n};\n\nexport function Button(buttonProps: ButtonProps): React.ReactElement {\n  // Kit components pass internal profiles through KitButton\n  // (button-internal.ts), the same function with the wider props type.\n  const {\n    \"aria-disabled\": ariaDisabledProp,\n    align = \"center\",\n    className,\n    variant,\n    size,\n    shape = \"default\",\n    render,\n    children,\n    loading = false,\n    multiline = false,\n    onClick,\n    onClickCapture,\n    onKeyDown,\n    onKeyDownCapture,\n    profile = \"default\",\n    profileMode,\n    profileTone,\n    width = \"auto\",\n    disabled: disabledProp,\n    ...props\n  } = buttonProps as ButtonImplementationProps;\n  const isDisabled: boolean = Boolean(loading || disabledProp);\n  const isAriaDisabled: boolean = Boolean(\n    isDisabled || ariaDisabledProp === true || ariaDisabledProp === \"true\",\n  );\n  const renderElement = React.isValidElement<ButtonRenderCaptureProps>(render)\n    ? render\n    : undefined;\n  const renderCaptureProps = renderElement?.props;\n  const guardedRender = renderElement\n    ? React.cloneElement(renderElement, {\n        onClickCapture: undefined,\n        onKeyDownCapture: undefined,\n      })\n    : render;\n  const rendersNativeButton = !render || renderElement?.type === \"button\";\n  // A field action is the glyph's own 24px target. The control size a form\n  // hands down must not turn a square one into a field-high box.\n  const inheritedSize = useResolvedControlSize(\n    profile === \"field-action\" && shape === \"square\" && size == null\n      ? \"icon-xs\"\n      : size,\n  );\n  const resolvedSize =\n    (shape === \"square\" || (profile === \"avatar\" && size == null)) &&\n    !inheritedSize.startsWith(\"icon\")\n      ? (`icon-${inheritedSize}` as\n          \"icon-xs\" | \"icon-sm\" | \"icon-md\" | \"icon-lg\" | \"icon-xl\")\n      : inheritedSize;\n  const resolvedVariant =\n    variant ?? (profile === \"field-action\" ? \"ghost\" : \"default\");\n  const selectTriggerProfileMode =\n    profile === \"select-trigger\"\n      ? (profileMode as SelectTriggerButtonMode | undefined)\n      : undefined;\n  const { chassis, skin } = buttonVariantBindings[resolvedVariant];\n  const variantLayers = buttonVariantEffectLayers[\n    resolvedVariant\n  ] as readonly string[];\n  const effectLayers = [\n    ...variantLayers,\n    ...(buttonProfileEffectLayers[profile] ?? []).filter(\n      (layer) => !variantLayers.includes(layer),\n    ),\n  ];\n  const typeValue: React.ButtonHTMLAttributes<HTMLButtonElement>[\"type\"] =\n    render ? undefined : \"button\";\n  // A Button of the default profile keeps its label on one line. Its text is\n  // a label that ends with an ellipsis where the Button meets the edge of its\n  // space, so a long label never makes its container wider (rulebook K5); the\n  // label rule in design-rework.css owns that. Other profiles keep their own\n  // text layout, and a multiline Button wraps.\n  const content =\n    profile === \"default\" && !multiline\n      ? wrapTextRuns(children, (text, index) => (\n          <span\n            className=\"sp-button-label\"\n            data-slot=\"button-label\"\n            key={`button-label-${index}`}\n            ref={labelTitleRef}\n          >\n            {text}\n          </span>\n        ))\n      : children;\n  const defaultProps = {\n    children: (\n      <>\n        {effectLayers.includes(\"drop-shadow\") && (\n          <span\n            aria-hidden=\"true\"\n            data-fx-layer=\"drop-shadow\"\n            className=\"sp-button-chassis-drop-shadow\"\n            data-slot=\"button-chassis-drop-shadow\"\n          />\n        )}\n        {effectLayers.includes(\"interaction\") && (\n          <span\n            aria-hidden=\"true\"\n            data-fx-layer=\"interaction\"\n            className=\"sp-button-chassis-interaction\"\n            data-slot=\"button-chassis-interaction\"\n          />\n        )}\n        {effectLayers.includes(\"spotlights\") && (\n          <span\n            aria-hidden=\"true\"\n            data-fx-layer=\"spotlights\"\n            className=\"sp-button-chassis-spotlights\"\n            data-slot=\"button-chassis-spotlights\"\n          />\n        )}\n        {effectLayers.includes(\"dome-overlay\") && (\n          <span\n            aria-hidden=\"true\"\n            data-fx-layer=\"dome-overlay\"\n            className=\"sp-button-chassis-dome-overlay\"\n            data-slot=\"button-chassis-dome-overlay\"\n          />\n        )}\n        {effectLayers.includes(\"rim\") && (\n          <span\n            aria-hidden=\"true\"\n            data-fx-layer=\"rim\"\n            className=\"sp-button-chassis-rim\"\n            data-slot=\"button-chassis-rim\"\n          />\n        )}\n        {effectLayers.includes(\"secondary-spotlights\") && (\n          <span\n            aria-hidden=\"true\"\n            data-fx-layer=\"secondary-spotlights\"\n            className=\"sp-button-chassis-secondary-spotlights\"\n            data-slot=\"button-chassis-secondary-spotlights\"\n          />\n        )}\n        {/* An empty, unstyled span (a label left empty) takes no slot, so it\n            adds no content gap beside the icon. */}\n        <span\n          className=\"sp-button-content [&>span:empty:not([class],[style],[data-slot])]:hidden\"\n          data-slot=\"button-content\"\n        >\n          {content}\n        </span>\n        {loading && (\n          <Spinner\n            className=\"pointer-events-none\"\n            data-slot=\"button-loading-indicator\"\n          />\n        )}\n      </>\n    ),\n    className: cn(\n      slotClass(\"button\", props, guardedRender),\n      buttonVariants({ size: resolvedSize, variant: resolvedVariant }),\n      resolvedVariant === \"glass\" && surfaceClassNames.glass,\n      \"sp-button-motion\",\n      // A multiline Button's height follows its lines, and one line stays on\n      // the control scale: both are design-rework.css's ([data-multiline]).\n      selectTriggerProfileMode &&\n        selectTriggerProfileModeClassNames[selectTriggerProfileMode],\n      width === \"fit\" && \"w-fit\",\n      className,\n    ),\n    \"aria-disabled\": isAriaDisabled || undefined,\n    \"data-align\": align === \"center\" ? undefined : align,\n    \"data-loading\": loading ? \"\" : undefined,\n    \"data-multiline\": multiline ? \"\" : undefined,\n    \"data-profile\": profile,\n    \"data-profile-mode\": profileMode,\n    \"data-profile-tone\": profileTone,\n    \"data-chassis\": chassis,\n    \"data-shape\": shape,\n    \"data-size\": resolvedSize,\n    \"data-skin\": skin,\n    \"data-slot\": \"button\",\n    \"data-variant\": resolvedVariant,\n    \"data-width\": width === \"auto\" ? undefined : width,\n    disabled: rendersNativeButton ? isDisabled : undefined,\n    onClickCapture:\n      isAriaDisabled || renderCaptureProps?.onClickCapture || onClickCapture\n        ? (event: React.MouseEvent<HTMLButtonElement>) => {\n            // This is the earliest owner-local guard. DOM ancestor capture\n            // handlers have already run; stopping those would require global\n            // interception.\n            if (isAriaDisabled) {\n              event.preventDefault();\n              event.stopPropagation();\n              return;\n            }\n\n            renderCaptureProps?.onClickCapture?.(event);\n            if (\n              !(\n                event as React.SyntheticEvent & {\n                  baseUIHandlerPrevented?: boolean;\n                }\n              ).baseUIHandlerPrevented\n            ) {\n              onClickCapture?.(event);\n            }\n          }\n        : undefined,\n    onClick:\n      isAriaDisabled || onClick\n        ? (event: React.MouseEvent<HTMLButtonElement>) => {\n            if (isAriaDisabled) {\n              event.preventDefault();\n              event.stopPropagation();\n              return;\n            }\n\n            onClick?.(event);\n          }\n        : undefined,\n    onMouseDown:\n      profile === \"field-action\" ? focusFieldActionOnPress : undefined,\n    onKeyDown:\n      isAriaDisabled || onKeyDown\n        ? (event: React.KeyboardEvent<HTMLButtonElement>) => {\n            if (\n              isAriaDisabled &&\n              (event.key === \"Enter\" || event.key === \" \")\n            ) {\n              event.preventDefault();\n              event.stopPropagation();\n              return;\n            }\n\n            onKeyDown?.(event);\n          }\n        : undefined,\n    onKeyDownCapture:\n      isAriaDisabled || renderCaptureProps?.onKeyDownCapture || onKeyDownCapture\n        ? (event: React.KeyboardEvent<HTMLButtonElement>) => {\n            // Keep the focusable aria-disabled element in place while blocking\n            // its target and bubble activation path.\n            if (\n              isAriaDisabled &&\n              (event.key === \"Enter\" || event.key === \" \")\n            ) {\n              event.preventDefault();\n              event.stopPropagation();\n              return;\n            }\n\n            renderCaptureProps?.onKeyDownCapture?.(event);\n            if (\n              !(\n                event as React.SyntheticEvent & {\n                  baseUIHandlerPrevented?: boolean;\n                }\n              ).baseUIHandlerPrevented\n            ) {\n              onKeyDownCapture?.(event);\n            }\n          }\n        : undefined,\n    type: typeValue,\n  };\n\n  return useRender({\n    defaultTagName: \"button\",\n    props: mergeProps<\"button\">(defaultProps, props),\n    render: guardedRender,\n  });\n}\n","type":"registry:ui","target":"~/src/siteplane/components/button/button.tsx"},{"path":"packages/ui/src/components/button/button-internal.ts","content":"import type * as React from \"react\";\n\nimport {\n  Button,\n  type ButtonBaseProps,\n  type ButtonProfile,\n  type ButtonProfileTone,\n  type InlineButtonProps,\n} from \"./button\";\n\n/*\n * Kit-internal Button. Not public API: the button index does not export this\n * module, and its names and profiles may change in any release.\n *\n * Kit components compose Button with these composition profiles, which give\n * Accordion, Tabs, Select, Sidebar and the other owners their geometry and\n * states. Applications use the owning component instead, or a documented\n * profile on the public Button.\n *\n * `step-trigger` is the header of a Stepper step: a text button around a\n * marker and its text. Its box reaches 4px past that content on every side\n * without moving it, so the keyboard focus outline clears the marker and the\n * marker stays on the connector; the height follows the content and the text\n * wraps.\n */\nexport type KitButtonProfile =\n  | ButtonProfile\n  | \"accordion-trigger\"\n  | \"animated-arrow\"\n  | \"animated-expand\"\n  | \"animated-fill\"\n  | \"chip-remove\"\n  | \"compact-icon-action\"\n  | \"compact-role-control\"\n  | \"collapsible-trigger\"\n  | \"full-cell-action\"\n  | \"joined-end\"\n  | \"joined-start\"\n  | \"menu-row\"\n  | \"morph-trigger\"\n  | \"navigation-row\"\n  | \"navigation-sub-row\"\n  | \"overlay-dismiss\"\n  | \"responsive-navigation\"\n  | \"select-trigger\"\n  | \"sidebar-rail\"\n  | \"step-trigger\"\n  | \"submenu-trigger\"\n  | \"tabs-tab\"\n  | \"task-pill\";\n\n/** The task-pill tones: a chart colour or muted. */\nexport type TaskPillTone =\n  \"chart-1\" | \"chart-2\" | \"chart-3\" | \"chart-4\" | \"chart-5\" | \"muted\";\n\nexport type SelectTriggerButtonMode =\n  \"default\" | \"elevated\" | \"ghost\" | \"inset\";\n\nexport type KitButtonProfileMode =\n  | ResponsiveNavigationButtonProps[\"profileMode\"]\n  | SelectTriggerButtonMode\n  | TabsTabButtonProps[\"profileMode\"];\n\nexport type KitButtonProfileTone = ButtonProfileTone | TaskPillTone;\n\ninterface KitDefaultProfileProps extends ButtonBaseProps {\n  profile?: Exclude<\n    KitButtonProfile,\n    | \"inline\"\n    | \"responsive-navigation\"\n    | \"select-trigger\"\n    | \"tabs-tab\"\n    | \"task-pill\"\n  >;\n  profileMode?: never;\n  profileTone?: never;\n}\n\ninterface ResponsiveNavigationButtonProps extends ButtonBaseProps {\n  profile: \"responsive-navigation\";\n  profileMode?: \"responsive\" | \"compact\";\n  profileTone?: never;\n}\n\ninterface TabsTabButtonProps extends ButtonBaseProps {\n  profile: \"tabs-tab\";\n  profileMode: \"default\" | \"pill\" | \"underline\";\n  profileTone?: never;\n}\n\ninterface SelectTriggerButtonProps extends ButtonBaseProps {\n  profile: \"select-trigger\";\n  profileMode: SelectTriggerButtonMode;\n  profileTone?: never;\n}\n\ninterface TaskPillButtonProps extends ButtonBaseProps {\n  profile: \"task-pill\";\n  profileMode?: never;\n  profileTone?: TaskPillTone;\n}\n\nexport type KitButtonProps =\n  | KitDefaultProfileProps\n  | (InlineButtonProps & { profileMode?: never })\n  | ResponsiveNavigationButtonProps\n  | SelectTriggerButtonProps\n  | TabsTabButtonProps\n  | TaskPillButtonProps;\n\n/** Button with the kit's composition profiles; the same component. */\nexport const KitButton = Button as (\n  props: KitButtonProps,\n) => React.ReactElement;\n","type":"registry:ui","target":"~/src/siteplane/components/button/button-internal.ts"},{"path":"packages/ui/src/components/button/button-recipes.tsx","content":"\"use client\";\n\nimport {\n  Children,\n  useState,\n  type MouseEventHandler,\n  type ReactElement,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"../../system/utils/cn\";\nimport { Badge } from \"../badge\";\nimport { Spinner } from \"../spinner\";\nimport { Button, type ButtonStandardProps } from \"./button\";\n\ntype StandardButtonPropsWithout<K extends keyof ButtonStandardProps> =\n  ButtonStandardProps extends infer Props\n    ? Props extends ButtonStandardProps\n      ? Omit<Props, K>\n      : never\n    : never;\n\ntype IconButtonSize = Extract<\n  NonNullable<ButtonStandardProps[\"size\"]>,\n  \"icon-lg\" | \"icon-md\" | \"icon-sm\" | \"icon-xl\" | \"icon-xs\"\n>;\n\nexport type IconButtonProps = StandardButtonPropsWithout<\n  \"aria-label\" | \"children\" | \"size\" | \"shape\"\n> & {\n  icon: ReactNode;\n  label: string;\n  /** A positive count, displayed as 99+ above 99. Include its meaning in label. */\n  count?: number;\n  size?: IconButtonSize;\n};\n\nexport function IconButton({\n  icon,\n  label,\n  count,\n  size,\n  ...props\n}: IconButtonProps): ReactElement {\n  return (\n    <Button aria-label={label} shape=\"square\" size={size} {...props}>\n      {icon}\n      {count !== undefined && Number.isFinite(count) && count > 0 && (\n        <Badge aria-hidden=\"true\" profile=\"count\">\n          {count > 99 ? \"99+\" : Math.ceil(count)}\n        </Badge>\n      )}\n    </Button>\n  );\n}\n\nexport type ButtonWithIconProps = StandardButtonPropsWithout<\"children\"> & {\n  children: ReactNode;\n  icon: ReactNode;\n  iconPosition?: \"end\" | \"start\";\n};\n\nexport function ButtonWithIcon({\n  children,\n  className,\n  icon,\n  iconPosition = \"start\",\n  ...props\n}: ButtonWithIconProps): ReactElement {\n  // An empty label renders no span, so it leaves no content gap beside the\n  // icon and the icon stays centred.\n  const hasLabel = Children.toArray(children).some((child) => child !== \"\");\n  return (\n    <Button className={cn(\"min-w-0\", className)} {...props}>\n      {iconPosition === \"start\" ? icon : null}\n      {hasLabel && <span className=\"truncate\">{children}</span>}\n      {iconPosition === \"end\" ? icon : null}\n    </Button>\n  );\n}\n\ntype LoadingButtonPhase = \"idle\" | \"loading\";\n\nexport type LoadingButtonProps = StandardButtonPropsWithout<\n  \"children\" | \"loading\" | \"onClick\"\n> & {\n  action?: () => Promise<unknown> | unknown;\n  children: ReactNode;\n  icon?: ReactNode;\n  iconPosition?: \"end\" | \"start\";\n  loading?: boolean;\n  loadingText?: ReactNode;\n  onClick?: MouseEventHandler<HTMLButtonElement>;\n  onError?: (error: unknown) => void;\n};\n\nexport function LoadingButton({\n  action,\n  children,\n  className,\n  disabled,\n  icon,\n  iconPosition = \"start\",\n  loading = false,\n  loadingText = \"Saving\",\n  onClick,\n  onError,\n  ...props\n}: LoadingButtonProps): ReactElement {\n  const [phase, setPhase] = useState<LoadingButtonPhase>(\"idle\");\n  const visualPhase: LoadingButtonPhase = loading ? \"loading\" : phase;\n  const isPending = visualPhase === \"loading\";\n\n  const handleClick: MouseEventHandler<HTMLButtonElement> = async (event) => {\n    if (visualPhase !== \"idle\" || disabled) return;\n    onClick?.(event);\n    if (event.defaultPrevented || !action) return;\n\n    setPhase(\"loading\");\n    try {\n      await Promise.resolve(action());\n    } catch (error) {\n      onError?.(error);\n      setPhase(\"idle\");\n      return;\n    }\n    setPhase(\"idle\");\n  };\n\n  const content = (\n    <span className=\"relative inline-grid min-w-0 items-center justify-items-center\">\n      <span\n        aria-hidden={isPending || undefined}\n        className=\"sp-loading-button-idle inline-flex min-w-0 items-center gap-2\"\n      >\n        {iconPosition === \"start\" ? icon : null}\n        {children}\n        {iconPosition === \"end\" ? icon : null}\n      </span>\n      <span\n        aria-hidden={!isPending || undefined}\n        aria-live=\"polite\"\n        className=\"sp-loading-button-pending absolute inset-0 inline-flex min-w-0 items-center justify-center gap-1.5\"\n      >\n        <Spinner aria-hidden className=\"size-4 shrink-0\" />\n        <span className=\"truncate\">{loadingText}</span>\n      </span>\n    </span>\n  );\n\n  return (\n    <span\n      className=\"sp-loading-button-shell\"\n      data-loading-button-phase={visualPhase}\n    >\n      <Button\n        {...props}\n        aria-busy={isPending || undefined}\n        className={cn(\"w-full overflow-hidden\", className)}\n        disabled={Boolean(disabled || isPending)}\n        onClick={handleClick}\n      >\n        {content}\n      </Button>\n    </span>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/button/button-recipes.tsx"},{"path":"packages/ui/src/components/button/button.css","content":"@layer theme, base, components, siteplane, utilities;\n\n@layer siteplane {\n  .sp-button-motion {\n    --sp-semantic-color-background: var(--color-white);\n    --sp-semantic-color-foreground: var(--color-neutral-800);\n    --sp-semantic-color-popover: var(--color-white);\n    --sp-semantic-color-primary: oklch(0.335 0.061 267.9);\n    --sp-semantic-color-primary-foreground: var(--color-neutral-50);\n    --sp-semantic-color-secondary: rgb(0 0 0 / 4%);\n    --sp-semantic-color-secondary-foreground: var(--color-neutral-800);\n    --sp-semantic-color-accent: rgb(0 0 0 / 4%);\n    --sp-semantic-color-destructive: var(--color-red-500);\n    --sp-semantic-color-destructive-foreground: var(--color-red-700);\n    --sp-semantic-color-input: rgb(0 0 0 / 10%);\n    --sp-semantic-color-ring: var(--color-neutral-400);\n    --sp-semantic-color-success: var(--color-emerald-500);\n    --sp-semantic-color-success-foreground: var(--color-emerald-700);\n    --sp-semantic-color-warning: var(--color-amber-500);\n    --sp-semantic-color-warning-foreground: var(--color-amber-700);\n    --button-text-base: oklch(0.466 0.027 271.6);\n    --button-text-hover-base: oklch(0.317 0.021 281.4);\n    --button-text: var(--button-text-base);\n    --button-text-hover: var(--button-text-hover-base);\n  }\n\n  .dark .sp-button-motion,\n  [data-theme=\"dark\"] .sp-button-motion {\n    --sp-semantic-color-background: color-mix(\n      in srgb,\n      var(--color-neutral-950) 95%,\n      var(--color-white)\n    );\n    --sp-semantic-color-foreground: var(--color-neutral-100);\n    --sp-semantic-color-popover: color-mix(\n      in srgb,\n      var(--sp-semantic-color-background) 98%,\n      var(--color-white)\n    );\n    --sp-semantic-color-primary-foreground: var(--color-neutral-50);\n    --sp-semantic-color-secondary: rgb(255 255 255 / 4%);\n    --sp-semantic-color-secondary-foreground: var(--color-neutral-100);\n    --sp-semantic-color-accent: rgb(255 255 255 / 4%);\n    --sp-semantic-color-destructive: color-mix(\n      in srgb,\n      var(--color-red-500) 90%,\n      var(--color-white)\n    );\n    --sp-semantic-color-destructive-foreground: var(--color-red-400);\n    --sp-semantic-color-input: rgb(255 255 255 / 8%);\n    --sp-semantic-color-ring: var(--color-neutral-500);\n    --sp-semantic-color-success-foreground: var(--color-emerald-400);\n    --sp-semantic-color-warning-foreground: var(--color-amber-400);\n    --button-text-base: oklch(0.879 0.016 270);\n    --button-text-hover-base: oklch(0.977 0.005 275);\n  }\n\n  .sp-button-motion .sp-button-loading-indicator {\n    position: absolute;\n    width: 1rem;\n    height: 1rem;\n  }\n\n  .sp-button-motion[data-loading] > .sp-button-content {\n    color: transparent;\n  }\n\n  .sp-loading-button-shell {\n    display: inline-flex;\n    max-width: 100%;\n  }\n\n  .sp-loading-button-idle,\n  .sp-loading-button-pending {\n    transition-property: opacity;\n    transition-duration: var(--motion-duration-micro);\n    transition-timing-function: var(--motion-ease-standard);\n  }\n\n  .sp-loading-button-shell[data-loading-button-phase=\"loading\"]\n    .sp-loading-button-idle,\n  .sp-loading-button-shell[data-loading-button-phase=\"idle\"]\n    .sp-loading-button-pending {\n    pointer-events: none;\n    opacity: 0;\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    .sp-button-motion,\n    .sp-loading-button-idle,\n    .sp-loading-button-pending,\n    .sp-button-motion .sp-button-loading-indicator {\n      transition-duration: 1ms;\n    }\n\n    .sp-button-motion .sp-button-loading-indicator {\n      animation: none;\n    }\n  }\n\n  html[data-motion-reduce=\"true\"] .sp-button-motion,\n  html[data-motion-reduce=\"true\"] .sp-loading-button-idle,\n  html[data-motion-reduce=\"true\"] .sp-loading-button-pending,\n  [data-sp-motion=\"reduced\"] .sp-button-motion,\n  [data-sp-motion=\"reduced\"] .sp-loading-button-idle,\n  [data-sp-motion=\"reduced\"] .sp-loading-button-pending,\n  html[data-motion-reduce=\"true\"]\n    .sp-button-motion\n    .sp-button-loading-indicator,\n  [data-sp-motion=\"reduced\"] .sp-button-motion .sp-button-loading-indicator {\n    transition-duration: 1ms;\n  }\n\n  html[data-motion-reduce=\"true\"]\n    .sp-button-motion\n    .sp-button-loading-indicator,\n  [data-sp-motion=\"reduced\"] .sp-button-motion .sp-button-loading-indicator {\n    animation: none;\n  }\n\n  /* Placement only: a Badge in an icon button is an overlay on the control's top\n   * trailing corner, never a second flex item. Its presentation (the count\n   * geometry) belongs to Badge's `count` profile. Only an icon button that\n   * contains a Badge anchors it to the control instead of the smaller content\n   * wrapper; every other icon button keeps its content above the chassis layers. */\n  :root\n    [data-chassis][data-size^=\"icon\"]\n    > .sp-button-content:has(> .sp-base-badge) {\n    position: static;\n  }\n\n  :root\n    [data-chassis][data-size^=\"icon\"]\n    > .sp-button-content\n    > .sp-base-badge {\n    position: absolute;\n    inset-block-start: -0.25rem;\n    inset-inline-end: -0.25rem;\n    z-index: 1;\n    pointer-events: none;\n  }\n\n  /* Avatar controls keep the portrait, interaction surface and focus ring circular. */\n  :root [data-chassis][data-profile=\"avatar\"][data-size] {\n    border-radius: 50%;\n    padding: 0;\n  }\n\n  :root [data-chassis][data-profile=\"avatar\"] > .sp-button-content {\n    height: 100%;\n    border-radius: inherit;\n  }\n\n  /* The portrait fills the whole control and has a surface of its own (a photo or\n   * the fallback fill), so the shared interaction layer, which lies beneath the\n   * content (z-index 30) in every other Button, would never show. Here it lies over\n   * the portrait instead. Its colour, hover, pressed and open levels stay the\n   * variant's own and it stays circular through the control's radius, so a hover,\n   * a press and an open menu darken the portrait, also over a photo. Only the\n   * stacking order differs: no state of its own is declared for the avatar. */\n  :root [data-chassis][data-profile=\"avatar\"] > .sp-button-chassis-interaction {\n    z-index: 31;\n  }\n\n  :root\n    [data-chassis][data-profile=\"avatar\"]\n    > .sp-button-content\n    > .sp-avatar {\n    width: 100%;\n    height: 100%;\n    border-radius: inherit;\n  }\n}\n","type":"registry:ui","target":"~/src/siteplane/components/button/button.css"},{"path":"packages/ui/src/components/button/index.ts","content":"export * from \"./button\";\nexport * from \"./button-recipes\";\n","type":"registry:ui","target":"~/src/siteplane/components/button/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/button.md","categories":["Actions"],"meta":{"avoidWhen":["Avoid for a state that stays pressed; use Toggle.","Avoid for a link inside running text; use a plain link."],"description":"Action family with variants, sizes, loading behavior, single-line icon labels, and accessible disabled states.","knownLimitations":[],"providerNotes":[],"releaseStatus":"stable","searchTerms":["button"],"useWhen":["Use for any action people trigger, such as submit, open, save or delete. Pass a link through render when the action navigates."]}}