// ~/src/siteplane/components/input/input.tsx "use client"; import { Input as InputPrimitive } from "@base-ui/react/input"; import * as React from "react"; import { type SiteplaneControlSize, useResolvedControlSize, } from "../../system/provider/siteplane-ui-provider"; import { formControlSurfaceClassNames, formControlTextClassNames, surfaceClassNames, type FormControlTextSize, type FormControlVariant, } from "../../system/styles/surface"; import { cn, type WithStringClassName } from "../../system/utils/cn"; import { dropSlotClass, slotClass } from "../../system/utils/slot-class"; import { useFieldLabelPlacement } from "../field"; import type { KitInputProps } from "./input-internal"; import "./input.css"; type InputRenderElementProps = React.ComponentProps<"input"> & { "data-slot"?: string; }; export type InputProps = Omit< WithStringClassName & React.RefAttributes, "size" > & { size?: SiteplaneControlSize | number; textSize?: FormControlTextSize; variant?: FormControlVariant; unstyled?: boolean; nativeInput?: boolean; renderInput?: React.ReactElement; hidePickerIndicator?: boolean; insetStart?: boolean | undefined; insetEnd?: boolean | undefined; }; const ExternalInputOwnerContext = React.createContext(false); /** * Kit-internal boundary; the component index does not export it. Components * whose own behavior owner already connects the input to its field (Base UI * Combobox and Autocomplete inputs, React Aria color fields, hidden form * adapters) render the Input chassis inside it. There, `nativeInput` and * `renderInput` pass the element through unchanged instead of registering it * with an enclosing Field a second time. */ export function ExternalInputOwner({ children, }: { children?: React.ReactNode; }): React.ReactElement { return ( {children} ); } export function Input(inputProps: InputProps): React.ReactElement { // Kit components pass the internal profiles through KitInput // (input-internal.ts), the same function with the wider props type. const { className, size, textSize, variant = "default", unstyled = false, nativeInput = false, renderInput, hidePickerIndicator = false, insetEnd = false, insetStart = false, profile = "default", profileMode, style, ...props } = inputProps as KitInputProps; const externalOwner = React.useContext(ExternalInputOwnerContext); const externalElement = externalOwner && Boolean(renderInput || nativeInput); // A floating Field lays out the text field it holds (design-rework.css, // "Floating label fields"). A hidden or file input keeps its own layout, and // so does an element another owner renders, such as the hidden proxy of a // segmented date field, unless it is the input of a Combobox or Autocomplete. // A floating field takes no leading icon, so every label and value in a form // starts on one line: an input whose start edge is inset for an adjoining // element, such as an icon, a Combobox or Autocomplete start addon or an // InputGroup addon, keeps the label above it. const floating = useFieldLabelPlacement() === "floating" && !insetStart && props.type !== "hidden" && props.type !== "file" && (!externalElement || (profile === "combobox" && profileMode !== "embedded")); // A blank placeholder lets CSS tell an empty field (:placeholder-shown), so // the label rests in an empty field and floats over a value before hydration. const floatingPlaceholder = floating && props.placeholder === undefined ? { placeholder: " " } : {}; const visualSize = typeof size === "number" ? undefined : size; const nativeSize = typeof size === "number" ? size : undefined; const resolvedSize = useResolvedControlSize(visualSize); const resolvedTextSize = textSize ?? (resolvedSize === "lg" || resolvedSize === "xl" ? "base" : undefined); // The native input fills its control: the field has no border, so a height // per size left a 2 px strip under it, or overflowed below 640 px. Its // height, radius, inline padding, line, placeholder and disabled cursor are // the kit base of every Input (design-rework.css). const inputClassName = cn( "w-full min-w-0 outline-none", props.type === "search" && "[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none", hidePickerIndicator && "sp-input-hide-picker", props.type === "date" && "sp-date-input", ); const nativeInputProps = props as React.ComponentProps<"input">; const nativeInputStyle = typeof style !== "function" && style !== undefined ? { style } : {}; // A hidden input only carries form data. The Legacy wrapper would otherwise // produce an empty visible surface. if (props.type === "hidden") { return ; } return ( {externalElement ? ( // The surrounding behavior owner already supplies id, labelling, // description and validation for this element. React.cloneElement(renderInput ?? , { ...nativeInputProps, ...floatingPlaceholder, className: cn( "sp-input", inputClassName, renderInput?.props.className, ), "data-slot": "input", size: nativeSize, ...nativeInputStyle, }) ) : ( // `nativeInput` and `renderInput` use the same Field control as the // default input, so FieldLabel, FieldDescription, FieldError and Field // state reach the rendered element. )} ); } export { InputPrimitive }; // ~/src/siteplane/components/input/input-internal.ts import type * as React from "react"; import { Input, type InputProps } from "./input"; /* * Kit-internal Input. Not public API: the input index does not export this * module, and its names and profiles may change in any release. Command, * Combobox, Autocomplete, InputGroup and Sidebar render their input with these * composition profiles. */ export type InputProfile = "command" | "combobox" | "default" | "input-group" | "sidebar"; export type InputProfileMode = "default" | "embedded"; export type KitInputProps = InputProps & { profile?: InputProfile; profileMode?: InputProfileMode; }; /** Input with the kit's composition profiles; the same component. */ export const KitInput = Input as (props: KitInputProps) => React.ReactElement; // ~/src/siteplane/components/input/input.css @layer theme, base, components, siteplane, utilities; @layer siteplane { .sp-input-control:not([data-unstyled]) { --sp-input-segment-background: color-mix( in srgb, var(--color-blue-500) 16%, transparent ); --sp-input-segment-padding-block: calc(var(--spacing) * 0.5); --sp-input-segment-padding-inline: var(--spacing); --sp-input-surface-subtle: rgb(0 0 0 / 4%); --sp-semantic-color-background: var(--color-white); --sp-semantic-color-border: rgb(0 0 0 / 8%); --sp-semantic-color-destructive: var(--color-red-500); --sp-semantic-color-foreground: var(--color-neutral-800); --sp-semantic-color-input: rgb(0 0 0 / 10%); --sp-semantic-color-muted-foreground: color-mix( in srgb, var(--color-neutral-500) 90%, var(--color-black) ); --sp-semantic-color-ring: var(--color-neutral-400); } .sp-base-input[data-profile="input-group"] { display: contents; } .sp-base-input[data-profile="input-group"]::before { display: none; } /* Embedded Combobox inputs keep the public Input layout contract while the * surrounding popup owns the visible surface and focus treatment. */ .sp-base-input[data-profile="combobox"][data-profile-mode="embedded"] { position: relative; display: inline-flex; width: 100%; min-width: 0; height: var(--sp-rw-control-md); } .sp-base-input[data-profile="combobox"][data-profile-mode="embedded"][data-size="sm"] { height: var(--sp-rw-control-sm); } .sp-base-input[data-profile="combobox"][data-profile-mode="embedded"][data-size="lg"] { height: var(--sp-rw-control-lg); } .sp-base-input[data-profile="combobox"][data-profile-mode="embedded"][data-size="xl"] { height: var(--sp-rw-control-xl); } .sp-base-input[data-profile="input-group"] .sp-input { flex: 1 1 0%; height: var(--sp-composite-control-height, 100%); border-radius: 0; padding-block-start: var(--sp-composite-control-padding-block-start, 0); padding-block-end: var(--sp-composite-control-padding-block-end, 0); padding-inline-start: var( --sp-composite-control-padding-inline-start, var(--sp-input-padding-inline, calc(var(--spacing) * 3)) ); padding-inline-end: var( --sp-composite-control-padding-inline-end, var(--sp-input-padding-inline, calc(var(--spacing) * 3)) ); } .dark .sp-input-control:not([data-unstyled]), [data-theme="dark"] .sp-input-control:not([data-unstyled]) { --sp-input-surface-subtle: rgb(255 255 255 / 4%); --sp-semantic-color-background: color-mix( in srgb, var(--color-neutral-950) 95%, var(--color-white) ); --sp-semantic-color-border: rgb(255 255 255 / 6%); --sp-semantic-color-destructive: color-mix( in srgb, var(--color-red-500) 90%, var(--color-white) ); --sp-semantic-color-foreground: var(--color-neutral-100); --sp-semantic-color-input: rgb(255 255 255 / 8%); --sp-semantic-color-muted-foreground: color-mix( in srgb, var(--color-neutral-500) 90%, var(--color-white) ); --sp-semantic-color-ring: var(--color-neutral-500); } .sp-input-control:not([data-unstyled])[data-variant="soft"] { background-color: var(--sp-input-surface-subtle); } .sp-input-control:not([data-unstyled])[data-variant="soft"]:focus-within { background-color: var(--sp-semantic-color-background); } .sp-input-control:not([data-unstyled]):has(:disabled) { background-color: var(--sp-input-surface-subtle); } .sp-input-control:not([data-unstyled]):has(:autofill) { background-color: color-mix( in oklab, var(--sp-semantic-color-foreground) 4%, transparent ); } :is(.dark, [data-theme="dark"]) .sp-input-control:not([data-unstyled]):has(:autofill) { background-color: color-mix( in oklab, var(--sp-semantic-color-foreground) 8%, transparent ); } .sp-input-hide-picker::-webkit-calendar-picker-indicator { display: none; } .sp-date-input { --input-segment-background: var(--sp-input-segment-background); --input-segment-padding-block: var(--sp-input-segment-padding-block); --input-segment-padding-inline: var(--sp-input-segment-padding-inline); } .sp-date-input::-webkit-calendar-picker-indicator { cursor: pointer; } .sp-date-input::-webkit-datetime-edit-day-field, .sp-date-input::-webkit-datetime-edit-month-field, .sp-date-input::-webkit-datetime-edit-year-field { border-radius: var(--radius-md); padding-block: var(--input-segment-padding-block); padding-inline: var(--input-segment-padding-inline); } .sp-date-input::-webkit-datetime-edit-day-field:focus, .sp-date-input::-webkit-datetime-edit-month-field:focus, .sp-date-input::-webkit-datetime-edit-year-field:focus { background-color: var(--input-segment-background); } /* One rule per selector: Chrome rejects a ::selection after another * pseudo-element, and one selector it rejects drops a whole list, so the * input's own selection rule stands alone. */ .sp-date-input::selection { background-color: transparent; } .sp-date-input::-webkit-datetime-edit-day-field::selection { background-color: transparent; } .sp-date-input::-webkit-datetime-edit-month-field::selection { background-color: transparent; } .sp-date-input::-webkit-datetime-edit-year-field::selection { background-color: transparent; } /* Embedded search headers: Input owns sizing, typography and native states. */ :root .sp-base-input[data-profile="command"] { display: flex; width: 100%; min-width: 0; height: var(--sp-rw-line-24); } :root .sp-base-input[data-profile="command"] .sp-input { flex: 1; height: var(--sp-rw-line-24); padding: 0; font-size: var(--sp-rw-font-16); line-height: var(--sp-rw-line-24); } :root .sp-base-input[data-profile="command"]:has([aria-invalid="true"]) { outline: 1px solid var(--destructive); } } // ~/src/siteplane/components/input/index.ts export { Input, InputPrimitive } from "./input"; export type { InputProps } from "./input"; // ~/src/siteplane/components/field/field.tsx "use client"; import { Field as FieldPrimitive } from "@base-ui/react/field"; import { DEFAULT_VALIDITY_STATE } from "@base-ui/react/internals/field-constants"; import { useFieldRootContext } from "@base-ui/react/internals/field-root-context"; import { useFormContext } from "@base-ui/react/internals/form-context"; import { LabelableProvider } from "@base-ui/react/internals/labelable-provider"; import * as React from "react"; import { useFormReset } from "../../system/form/use-form-reset"; import { useMediaQuery } from "../../system/hooks/use-media-query"; import { cn, type WithStringClassName } from "../../system/utils/cn"; import { slotClass } from "../../system/utils/slot-class"; import { MotionReveal } from "../motion"; import { Tooltip, TooltipPopup } from "../tooltip"; import { type FieldErrorState, FieldErrorStateProvider, isFieldErrorShown, useFieldErrorState, } from "./field-error-presence"; export type FieldLabelPlacement = "top" | "floating"; const FieldLabelPlacementContext = React.createContext("top"); /** * Where the enclosing Field draws its label: "top" above the control (the * default, and outside a Field) or "floating" inside it. Controls read it to * lay themselves out around a floating label. */ export function useFieldLabelPlacement(): FieldLabelPlacement { return React.useContext(FieldLabelPlacementContext); } /** Whether focus is in the enclosing Field (see `fieldHoldsFocus`). */ const FieldFocusContext = React.createContext(false); /** * Focus is in a field while it is in its control, in an action inside it such * as a password's show/hide button, or in an open popup of it, whose trigger * inside the field is then expanded. */ function fieldHoldsFocus(field: HTMLElement): boolean { return ( field.contains(field.ownerDocument.activeElement) || field.querySelector('[aria-expanded="true"]') !== null ); } /** * The space between a Field's label, control, description and error is * `--field-gap` (8 px). FieldError reads the same value, so the space it adds * opens and closes with the error instead of appearing in one step. * * `labelPlacement="floating"` draws the label inside the control: at rest it * stands in for the placeholder, and it glides to the top once the field has * focus or a value (design-rework.css, "Floating label fields"). * * The Field follows whether focus is in it (`fieldHoldsFocus`): its error * tooltip shows only then, so one bubble shows at a time. With * `validationMode="onBlur"`, from the Field or its Form, leaving the field * validates it in full, so a required field that is left empty warns. Base UI * reports a missing value only once the value has changed; the Field asks Base * UI's own validate action instead. * * `requiredIndicator` marks the label of a required field with a red asterisk * (design-rework.css). It is a choice per form; the control's own `required` * stays the one source, so the asterisk shows only while the control is * required. * * A native reset of the form around the Field returns it to how it was before * the form's first submit (FieldFormReset). */ export function Field({ actionsRef, children, className, labelPlacement = "top", onBlur, onFocus, ref, requiredIndicator = false, ...props }: WithStringClassName & { labelPlacement?: FieldLabelPlacement; /** Marks the label with a red asterisk while the control is required. */ requiredIndicator?: boolean | undefined; }): React.ReactElement { const formValidationMode = useFormContext().validationMode; const validatesOnBlur = (props.validationMode ?? formValidationMode) === "onBlur"; const actions = React.useRef(null); // The caller's actionsRef reaches the same validate action. React.useImperativeHandle( actionsRef, () => ({ validate: () => actions.current?.validate() }), [], ); // Whether focus is in the field, and what leaving it does. const fieldRef = React.useRef(null); const [focused, setFocused] = React.useState(false); const focusedRef = React.useRef(false); const validatesOnBlurRef = React.useRef(validatesOnBlur); React.useLayoutEffect(() => { validatesOnBlurRef.current = validatesOnBlur; }); const follow = React.useCallback((holds: boolean) => { const left = focusedRef.current && !holds; focusedRef.current = holds; setFocused(holds); if (left && validatesOnBlurRef.current) actions.current?.validate(); }, []); // Wait until focus has settled: a field action keeps it inside the field, // and an opening popup marks its trigger expanded. const settle = React.useCallback(() => { requestAnimationFrame(() => { const field = fieldRef.current; if (field) follow(fieldHoldsFocus(field)); }); }, [follow]); // A control that has focus before hydration, such as an autofocused one. React.useLayoutEffect(() => { const field = fieldRef.current; if (field && fieldHoldsFocus(field)) follow(true); }, [follow]); // While focus is in the field, a popup of it can close with focus elsewhere, // so the field follows the document's focus moves until focus has left. React.useEffect(() => { if (!focused) return undefined; const document = fieldRef.current?.ownerDocument; if (!document) return undefined; document.addEventListener("focusin", settle); document.addEventListener("focusout", settle); return () => { document.removeEventListener("focusin", settle); document.removeEventListener("focusout", settle); }; }, [focused, settle]); // The reset is cleared inside the Field root, where its state lives. const resetField = React.useRef<(() => void) | null>(null); const formResetRef = useFormReset(undefined, () => resetField.current?.(), ); const setFieldRef = React.useCallback( (element: HTMLDivElement | null) => { fieldRef.current = element; formResetRef.current = element; if (typeof ref === "function") ref(element); else if (ref) ref.current = element; }, [formResetRef, ref], ); return ( { onBlur?.(event); settle(); }} onFocus={(event) => { onFocus?.(event); follow(true); }} ref={setFieldRef} {...props} > {children} ); } /** * A native reset puts the form's values back to their defaults, and Base UI * keeps the Field's validity, its touched and dirty marks and the Form's * submitted state as they were; it does not follow a reset. The reset clears * them, so the Field is as it was before the form's first submit: its error * collapses as when the value becomes valid, its controls drop a custom * validity message, and the Form validates again on its next submit. Errors * the application passes to Form clear with the same reset (Form). It renders * nothing. */ function FieldFormReset({ field: fieldElement, handle, }: { field: React.RefObject; handle: React.RefObject<(() => void) | null>; }): null { const field = useFieldRootContext(false); const form = useFormContext(); React.useLayoutEffect(() => { handle.current = () => { form.submitAttemptedRef.current = false; for (const control of fieldElement.current?.querySelectorAll< HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement >("input, select, textarea") ?? []) control.setCustomValidity(""); field.setValidityData((data) => ({ ...data, error: "", errors: [], state: DEFAULT_VALIDITY_STATE, value: data.initialValue, })); field.setTouched(false); field.setDirty(false); }; }); return null; } /** * A label above a field sits on the 16 px line; one beside a checkbox, radio * or switch (holding it, next to it or leading the text column next to it) * takes the 21 px body line, since such labels are sentences and wrap (S13). * Label and FieldLabel share that rule in design-rework.css. A label of text * alone lays out as a block, so a required asterisk follows its last word when * it wraps. */ export function FieldLabel({ className, ...props }: WithStringClassName): React.ReactElement { return ( ); } export function FieldItem({ className, ...props }: WithStringClassName): React.ReactElement { return ( ); } export function FieldDescription({ className, ...props }: WithStringClassName): React.ReactElement { return ( ); } export type FieldErrorVariant = "inline" | "tooltip"; /** * The message of a failed validation. It expands with a fade when it appears * and collapses while it still shows its last message when it goes, so the * form below it never jumps. Showing and hiding follow Base UI's Field.Error. * * The block's negative margin takes back the Field's gap and the message's own * padding gives it again, so the space opens and closes with the block. * * `variant="tooltip"` shows the same message in a bubble beside the control * instead, so nothing below it moves. It is the default in a floating Field, * and `inline` everywhere else. */ export function FieldError({ className, match, variant, ...props }: WithStringClassName & { /** * `inline` reveals the message below the control; `tooltip` shows it in a * destructive bubble beside the control, out of the flow. The default is * `tooltip` in a floating Field and `inline` otherwise. */ variant?: FieldErrorVariant | undefined; }): React.ReactElement { const state = useFieldErrorState(); const placement = useFieldLabelPlacement(); const resolvedVariant = variant ?? (placement === "floating" ? "tooltip" : "inline"); if (resolvedVariant === "tooltip") return ( ); return ( {/* The motion component decides whether the error is shown, so the element itself always is. */} ); } /** Parts of a Field that are not its control. */ const fieldParts = new Set([ "field-error-anchor", "field-error-description", "field-description", "field-label", "motion-reveal", ]); /** * The visible control of the Field around `element`: its first child that is * no label, description, error or bare input (such as a Select's hidden form * input), or the Field itself. */ function fieldControlOf(element: Element | null): Element | null { const field = element?.closest('[data-slot="field"]') ?? null; if (!field) return null; for (const child of Array.from(field.children)) { if ( child instanceof HTMLInputElement || child.hasAttribute("hidden") || fieldParts.has(child.getAttribute("data-slot") ?? "") ) continue; return child; } return field; } /** * The tooltip variant of FieldError: the kit Tooltip, anchored to the Field's * control, in the destructive tone with an arrow. It shows while the error is * shown and focus is in the field, so at most one bubble shows at a time, as * the browser's own validation bubble does; other invalid fields show the * invalid frame. It sits at the control's inline end, centred, or below it, * start-aligned, on a narrow screen; Base UI places it and flips it when there * is no room. It never takes the pointer or focus. The bubble keeps the message * of its last shown render while it fades out. * * The control's error description is a visually hidden Field.Error beside the * anchor, there whenever the error is shown, with focus in the field or not; * the bubble only presents the same message, so it registers no description * of its own and is hidden from assistive technology. */ function FieldErrorTooltip({ className, shown, state, ...props }: WithStringClassName & { shown: boolean; state: FieldErrorState; }): React.ReactElement { // The description keeps a given id; the bubble takes its own. const bubbleProps = { ...props, id: undefined }; const anchorRef = React.useRef(null); const focused = React.useContext(FieldFocusContext); const beside = useMediaQuery("sm"); const [heldState, setHeldState] = React.useState(state); if ( shown && (heldState.field !== state.field || heldState.form !== state.form) ) setHeldState(state); return ( <> {/* Finds the Field in the document; the bubble itself is portalled. */}