// ~/src/siteplane/components/otp-field/otp-field.tsx "use client"; import { OTPField as BaseOTPField } from "@base-ui/react/otp-field"; import { Separator as BaseSeparator } from "@base-ui/react/separator"; import * as React from "react"; import { type SiteplaneControlSize, useResolvedControlSize, } from "../../system/provider/siteplane-ui-provider"; import { formControlSurfaceClassNames, type FormControlSurface, surfaceClassNames, } from "../../system/styles/surface"; import { cn, type WithStringClassName } from "../../system/utils/cn"; import { slotClass } from "../../system/utils/slot-class"; import { InputGroup } from "../input-group"; import { Separator } from "../separator"; import { Label } from "../label"; import "./otp-field.css"; /** Base UI 1.7 removed Separator from the OTP namespace. */ export const OTPFieldPrimitive = { Input: BaseOTPField.Input, Root: BaseOTPField.Root, Separator: BaseSeparator, } as const; /** * `separate` draws one box per slot. `joined` draws the slots as one field * with the surface of an Input and a thin divider between the characters. */ export type OTPFieldLayout = "separate" | "joined"; const OTPFieldContext = React.createContext<{ layout: OTPFieldLayout; variant: FormControlSurface; }>({ layout: "separate", variant: "default" }); export type OTPFieldProps = WithStringClassName< React.ComponentProps > & { layout?: OTPFieldLayout | undefined; size?: SiteplaneControlSize | undefined; variant?: FormControlSurface | undefined; }; export function OTPField({ "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, children, className, id, layout = "separate", size, variant = "default", ...props }: OTPFieldProps): React.ReactElement { const resolvedSize = useResolvedControlSize(size); const generatedInputId = React.useId(); const resolvedInputId = id ?? (ariaLabel ? generatedInputId : undefined); const joined = layout === "joined"; const context = React.useMemo(() => ({ layout, variant }), [layout, variant]); return ( {ariaLabel && !ariaLabelledBy ? ( ) : null} ), } : {})} {...props} > {children} ); } export type OTPFieldInputProps = WithStringClassName< React.ComponentProps >; /** * What a slot shows in both layouts: the character is centred, and there is no * caret and no selection highlight, so the focus ring is the only mark of the * current slot. */ const slotClassName = "text-center caret-transparent selection:bg-transparent selection:text-foreground"; export function OTPFieldInput({ className, ...props }: OTPFieldInputProps): React.ReactElement { const { layout, variant } = React.useContext(OTPFieldContext); if (layout === "joined") { // The field draws the surface; the slot is a bare cell inside it that keeps // the state logic of a separate slot (otp-field.css). One divider precedes // each slot and only the ones that follow another slot show, so every pair // of characters is separated. return ( <> ); } return ( ); } export type OTPFieldSeparatorProps = React.ComponentProps; export function OTPFieldSeparator({ className, ...props }: OTPFieldSeparatorProps): React.ReactElement { return ( } /> ); } // ~/src/siteplane/components/otp-field/otp-field.css @layer theme, base, components, siteplane, utilities; @layer siteplane { /* Joined layout (layout="joined"). * * The root is an InputGroup, so the field surface and its invalid state come * from there, exactly as for an Input. The slots keep the state logic of the * separate slots and only change where it is drawn: * * - The current slot, the focused one, takes the focus ring of a separate slot, * inside the field. The field itself takes no focus ring (design-rework.css), * so the ring marks the slot and not the whole field. * - There is no caret and no highlight fill, as in a separate slot. * - The other slots stay bare cells in the surface of the field. * - A disabled field keeps its surface and fades, like the separate slots. */ /* An xl Input has the larger surface radius; the joined field follows it. */ .sp-otp-field[data-layout="joined"][data-size="xl"] { border-radius: var(--sp-rw-radius-surface); } /* A slot inside the field. The `[data-size]` term ranks this above the * per-size rules that give a separate slot its own box: the slot takes its * share of the field, and its ring runs flush along the edge of the field. */ :root .sp-otp-field[data-layout="joined"][data-size] > .sp-otp-field-input { flex: 1 1 auto; min-width: 0; border-radius: 0; background: none; } /* The first and the last slot follow the corners of the field. */ :root .sp-otp-field[data-layout="joined"][data-size] > .sp-otp-field-input:first-of-type { border-start-start-radius: inherit; border-end-start-radius: inherit; } :root .sp-otp-field[data-layout="joined"][data-size] > .sp-otp-field-input:last-of-type { border-start-end-radius: inherit; border-end-end-radius: inherit; } /* Only the focused slot is drawn. Its ring comes from the rule that gives a * separate slot its ring (design-rework.css): the focus ring, or the invalid * contour of an invalid field. */ :root .sp-otp-field[data-layout="joined"][data-size] > .sp-otp-field-input:not(:focus-visible) { box-shadow: none; } :root .sp-otp-field[data-layout="joined"][data-size] > .sp-otp-field-input:disabled { cursor: not-allowed; } /* The InputGroup darkens a disabled field with a subtle fill. A disabled OTP * Field keeps its surface and only fades, in both layouts. */ :root .sp-otp-field[data-layout="joined"]:has(.sp-otp-field-input:disabled) { background-color: transparent; } /* One divider precedes every slot. Only the dividers that follow another slot * show, so the first slot and the slot after a separator start without one. */ .sp-otp-field[data-layout="joined"] > .sp-separator[data-orientation="vertical"] { display: none; } .sp-otp-field[data-layout="joined"] > .sp-otp-field-input + .sp-separator[data-orientation="vertical"] { display: block; flex: none; align-self: center; width: var(--sp-rw-border-default); height: var(--sp-rw-space-4); border-radius: 0; transition: opacity var(--motion-duration-fast) var(--motion-ease-state); } /* The dividers on both sides of the current slot give way to its ring. */ .sp-otp-field[data-layout="joined"] > .sp-separator[data-orientation="vertical"]:has( + .sp-otp-field-input:focus-visible ), .sp-otp-field[data-layout="joined"] > .sp-otp-field-input:focus-visible + .sp-separator[data-orientation="vertical"] { opacity: 0; } /* OTPFieldSeparator still groups slots of a joined field with its short dash; * the dash keeps space on both sides because the field has no gap. */ .sp-otp-field[data-layout="joined"] > .sp-separator[data-orientation="horizontal"] { flex: none; margin-inline: var(--sp-rw-space-2); } } // ~/src/siteplane/components/otp-field/index.ts export * from "./otp-field";