{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"otp-field","title":"OTP Field","description":"Segmented fixed-length input for one-time passwords, verification codes and recovery codes. Search terms: joined.","dependencies":["@base-ui/react@1.7.0"],"registryDependencies":["@siteplane/base","@siteplane/input-group","@siteplane/label","@siteplane/separator"],"files":[{"path":"packages/ui/src/components/otp-field/otp-field.tsx","content":"\"use client\";\n\nimport { OTPField as BaseOTPField } from \"@base-ui/react/otp-field\";\nimport { Separator as BaseSeparator } from \"@base-ui/react/separator\";\nimport * as React from \"react\";\n\nimport {\n  type SiteplaneControlSize,\n  useResolvedControlSize,\n} from \"../../system/provider/siteplane-ui-provider\";\nimport {\n  formControlSurfaceClassNames,\n  type FormControlSurface,\n  surfaceClassNames,\n} from \"../../system/styles/surface\";\nimport { cn, type WithStringClassName } from \"../../system/utils/cn\";\nimport { slotClass } from \"../../system/utils/slot-class\";\nimport { InputGroup } from \"../input-group\";\nimport { Separator } from \"../separator\";\nimport { Label } from \"../label\";\n\nimport \"./otp-field.css\";\n\n/** Base UI 1.7 removed Separator from the OTP namespace. */\nexport const OTPFieldPrimitive = {\n  Input: BaseOTPField.Input,\n  Root: BaseOTPField.Root,\n  Separator: BaseSeparator,\n} as const;\n\n/**\n * `separate` draws one box per slot. `joined` draws the slots as one field\n * with the surface of an Input and a thin divider between the characters.\n */\nexport type OTPFieldLayout = \"separate\" | \"joined\";\n\nconst OTPFieldContext = React.createContext<{\n  layout: OTPFieldLayout;\n  variant: FormControlSurface;\n}>({ layout: \"separate\", variant: \"default\" });\n\nexport type OTPFieldProps = WithStringClassName<\n  React.ComponentProps<typeof OTPFieldPrimitive.Root>\n> & {\n  layout?: OTPFieldLayout | undefined;\n  size?: SiteplaneControlSize | undefined;\n  variant?: FormControlSurface | undefined;\n};\n\nexport function OTPField({\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  children,\n  className,\n  id,\n  layout = \"separate\",\n  size,\n  variant = \"default\",\n  ...props\n}: OTPFieldProps): React.ReactElement {\n  const resolvedSize = useResolvedControlSize(size);\n  const generatedInputId = React.useId();\n  const resolvedInputId = id ?? (ariaLabel ? generatedInputId : undefined);\n  const joined = layout === \"joined\";\n  const context = React.useMemo(() => ({ layout, variant }), [layout, variant]);\n\n  return (\n    <OTPFieldContext.Provider value={context}>\n      {ariaLabel && !ariaLabelledBy ? (\n        <Label className=\"sr-only\" htmlFor={resolvedInputId}>\n          {ariaLabel}\n        </Label>\n      ) : null}\n      <OTPFieldPrimitive.Root\n        aria-labelledby={ariaLabelledBy}\n        className={cn(\n          slotClass(\"otp-field\", props),\n          !joined && \"flex items-center gap-2\",\n          className,\n        )}\n        data-layout={layout}\n        data-size={resolvedSize}\n        data-slot=\"otp-field\"\n        data-variant={variant}\n        id={resolvedInputId}\n        // A joined field is an InputGroup: the field surface and its invalid\n        // state stay with the component that owns them, with the same text size\n        // as an Input. Focus is not the field's: the current slot draws the ring\n        // of a separate slot (otp-field.css).\n        {...(joined\n          ? {\n              render: (\n                <InputGroup\n                  textSize={\n                    resolvedSize === \"lg\" || resolvedSize === \"xl\"\n                      ? \"base\"\n                      : undefined\n                  }\n                  variant={variant}\n                />\n              ),\n            }\n          : {})}\n        {...props}\n      >\n        {children}\n      </OTPFieldPrimitive.Root>\n    </OTPFieldContext.Provider>\n  );\n}\n\nexport type OTPFieldInputProps = WithStringClassName<\n  React.ComponentProps<typeof OTPFieldPrimitive.Input>\n>;\n\n/**\n * What a slot shows in both layouts: the character is centred, and there is no\n * caret and no selection highlight, so the focus ring is the only mark of the\n * current slot.\n */\nconst slotClassName =\n  \"text-center caret-transparent selection:bg-transparent selection:text-foreground\";\n\nexport function OTPFieldInput({\n  className,\n  ...props\n}: OTPFieldInputProps): React.ReactElement {\n  const { layout, variant } = React.useContext(OTPFieldContext);\n\n  if (layout === \"joined\") {\n    // The field draws the surface; the slot is a bare cell inside it that keeps\n    // the state logic of a separate slot (otp-field.css). One divider precedes\n    // each slot and only the ones that follow another slot show, so every pair\n    // of characters is separated.\n    return (\n      <>\n        <Separator orientation=\"vertical\" role=\"presentation\" />\n        <OTPFieldPrimitive.Input\n          className={cn(\n            slotClass(\"otp-field-input\", props),\n            slotClassName,\n            className,\n          )}\n          data-slot=\"otp-field-input\"\n          spellCheck={false}\n          {...props}\n        />\n      </>\n    );\n  }\n\n  return (\n    <OTPFieldPrimitive.Input\n      className={cn(\n        slotClass(\"otp-field-input\", props),\n        surfaceClassNames.formControlCell,\n        formControlSurfaceClassNames[variant],\n        slotClassName,\n        \"in-[[data-slot=otp-field][data-size=sm]]:text-sm in-[[data-slot=otp-field][data-size=lg]]:text-lg in-[[data-slot=otp-field][data-size=xl]]:text-lg in-[[data-slot=otp-field][data-size=sm]]:leading-8 in-[[data-slot=otp-field][data-size=lg]]:leading-10 in-[[data-slot=otp-field][data-size=xl]]:leading-6 leading-9 sm:in-[[data-slot=otp-field][data-size=lg]]:text-base sm:in-[[data-slot=otp-field][data-size=xl]]:text-lg sm:in-[[data-slot=otp-field][data-size=sm]]:leading-7 sm:in-[[data-slot=otp-field][data-size=lg]]:leading-9 sm:in-[[data-slot=otp-field][data-size=xl]]:leading-6 sm:leading-8\",\n        className,\n      )}\n      data-slot=\"otp-field-input\"\n      spellCheck={false}\n      {...props}\n    />\n  );\n}\n\nexport type OTPFieldSeparatorProps = React.ComponentProps<typeof Separator>;\n\nexport function OTPFieldSeparator({\n  className,\n  ...props\n}: OTPFieldSeparatorProps): React.ReactElement {\n  return (\n    <OTPFieldPrimitive.Separator\n      render={\n        <Separator className={className} orientation=\"horizontal\" {...props} />\n      }\n    />\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/otp-field/otp-field.tsx"},{"path":"packages/ui/src/components/otp-field/otp-field.css","content":"@layer theme, base, components, siteplane, utilities;\n\n@layer siteplane {\n  /* Joined layout (layout=\"joined\").\n   *\n   * The root is an InputGroup, so the field surface and its invalid state come\n   * from there, exactly as for an Input. The slots keep the state logic of the\n   * separate slots and only change where it is drawn:\n   *\n   * - The current slot, the focused one, takes the focus ring of a separate slot,\n   *   inside the field. The field itself takes no focus ring (design-rework.css),\n   *   so the ring marks the slot and not the whole field.\n   * - There is no caret and no highlight fill, as in a separate slot.\n   * - The other slots stay bare cells in the surface of the field.\n   * - A disabled field keeps its surface and fades, like the separate slots. */\n\n  /* An xl Input has the larger surface radius; the joined field follows it. */\n  .sp-otp-field[data-layout=\"joined\"][data-size=\"xl\"] {\n    border-radius: var(--sp-rw-radius-surface);\n  }\n\n  /* A slot inside the field. The `[data-size]` term ranks this above the\n   * per-size rules that give a separate slot its own box: the slot takes its\n   * share of the field, and its ring runs flush along the edge of the field. */\n  :root .sp-otp-field[data-layout=\"joined\"][data-size] > .sp-otp-field-input {\n    flex: 1 1 auto;\n    min-width: 0;\n    border-radius: 0;\n    background: none;\n  }\n\n  /* The first and the last slot follow the corners of the field. */\n  :root\n    .sp-otp-field[data-layout=\"joined\"][data-size]\n    > .sp-otp-field-input:first-of-type {\n    border-start-start-radius: inherit;\n    border-end-start-radius: inherit;\n  }\n\n  :root\n    .sp-otp-field[data-layout=\"joined\"][data-size]\n    > .sp-otp-field-input:last-of-type {\n    border-start-end-radius: inherit;\n    border-end-end-radius: inherit;\n  }\n\n  /* Only the focused slot is drawn. Its ring comes from the rule that gives a\n   * separate slot its ring (design-rework.css): the focus ring, or the invalid\n   * contour of an invalid field. */\n  :root\n    .sp-otp-field[data-layout=\"joined\"][data-size]\n    > .sp-otp-field-input:not(:focus-visible) {\n    box-shadow: none;\n  }\n\n  :root\n    .sp-otp-field[data-layout=\"joined\"][data-size]\n    > .sp-otp-field-input:disabled {\n    cursor: not-allowed;\n  }\n\n  /* The InputGroup darkens a disabled field with a subtle fill. A disabled OTP\n   * Field keeps its surface and only fades, in both layouts. */\n  :root .sp-otp-field[data-layout=\"joined\"]:has(.sp-otp-field-input:disabled) {\n    background-color: transparent;\n  }\n\n  /* One divider precedes every slot. Only the dividers that follow another slot\n   * show, so the first slot and the slot after a separator start without one. */\n  .sp-otp-field[data-layout=\"joined\"]\n    > .sp-separator[data-orientation=\"vertical\"] {\n    display: none;\n  }\n\n  .sp-otp-field[data-layout=\"joined\"]\n    > .sp-otp-field-input\n    + .sp-separator[data-orientation=\"vertical\"] {\n    display: block;\n    flex: none;\n    align-self: center;\n    width: var(--sp-rw-border-default);\n    height: var(--sp-rw-space-4);\n    border-radius: 0;\n    transition: opacity var(--motion-duration-fast) var(--motion-ease-state);\n  }\n\n  /* The dividers on both sides of the current slot give way to its ring. */\n  .sp-otp-field[data-layout=\"joined\"]\n    > .sp-separator[data-orientation=\"vertical\"]:has(\n      + .sp-otp-field-input:focus-visible\n    ),\n  .sp-otp-field[data-layout=\"joined\"]\n    > .sp-otp-field-input:focus-visible\n    + .sp-separator[data-orientation=\"vertical\"] {\n    opacity: 0;\n  }\n\n  /* OTPFieldSeparator still groups slots of a joined field with its short dash;\n   * the dash keeps space on both sides because the field has no gap. */\n  .sp-otp-field[data-layout=\"joined\"]\n    > .sp-separator[data-orientation=\"horizontal\"] {\n    flex: none;\n    margin-inline: var(--sp-rw-space-2);\n  }\n}\n","type":"registry:ui","target":"~/src/siteplane/components/otp-field/otp-field.css"},{"path":"packages/ui/src/components/otp-field/index.ts","content":"export * from \"./otp-field\";\n","type":"registry:ui","target":"~/src/siteplane/components/otp-field/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/otp-field.md","categories":["Forms & selection"],"meta":{"avoidWhen":["Avoid for a code of variable length or free text; use Input."],"description":"Segmented fixed-length input for one-time passwords, verification codes and recovery codes.","knownLimitations":["A completed code is not verified: the server validates it and limits retries."],"providerNotes":[],"releaseStatus":"stable","searchTerms":["otp-field","otp field","otp","field","joined"],"useWhen":["Use to enter a short code of fixed length, such as a one-time password, a verification code or a recovery code."]}}