- {/* biome-ignore lint/a11y/useKeyWithClickEvents: this div needs an onClick to show the input on mobile, where it's normally hidden.
- Normally you'd also need to add a keyboard trigger to do the same without a pointer, but in this case the input already be focused on its own. */}
-
- {value && isOpen ? (
-
);
}
diff --git a/packages/gitbook/src/components/hooks/useControlledState.tsx b/packages/gitbook/src/components/hooks/useControlledState.tsx
new file mode 100644
index 0000000000..457e51b9b7
--- /dev/null
+++ b/packages/gitbook/src/components/hooks/useControlledState.tsx
@@ -0,0 +1,83 @@
+/**
+ * Hook to manage a controlled state.
+ *
+ * From https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/utils/src/useControlledState.ts
+ */
+
+import React, {
+ type SetStateAction,
+ useCallback,
+ useEffect,
+ useReducer,
+ useRef,
+ useState,
+} from 'react';
+
+// Use the earliest effect possible to reset the ref below.
+const useEarlyEffect: typeof React.useLayoutEffect =
+ typeof document !== 'undefined' ? React.useInsertionEffect || React.useLayoutEffect : () => {};
+
+export function useControlledState(
+ value: Exclude,
+ defaultValue: Exclude | undefined,
+ onChange?: (v: C, ...args: any[]) => void
+): [T, (value: SetStateAction, ...args: any[]) => void];
+export function useControlledState(
+ value: Exclude | undefined,
+ defaultValue: Exclude,
+ onChange?: (v: C, ...args: any[]) => void
+): [T, (value: SetStateAction, ...args: any[]) => void];
+export function useControlledState(
+ value: T,
+ defaultValue: T,
+ onChange?: (v: C, ...args: any[]) => void
+): [T, (value: SetStateAction, ...args: any[]) => void] {
+ // Store the value in both state and a ref. The state value will only be used when uncontrolled.
+ // The ref is used to track the most current value, which is passed to the function setState callback.
+ const [stateValue, setStateValue] = useState(value || defaultValue);
+ const valueRef = useRef(stateValue);
+
+ const isControlledRef = useRef(value !== undefined);
+ const isControlled = value !== undefined;
+ useEffect(() => {
+ const wasControlled = isControlledRef.current;
+ if (wasControlled !== isControlled && process.env.NODE_ENV !== 'production') {
+ console.warn(
+ `WARN: A component changed from ${wasControlled ? 'controlled' : 'uncontrolled'} to ${isControlled ? 'controlled' : 'uncontrolled'}.`
+ );
+ }
+ isControlledRef.current = isControlled;
+ }, [isControlled]);
+
+ // After each render, update the ref to the current value.
+ // This ensures that the setState callback argument is reset.
+ // Note: the effect should not have any dependencies so that controlled values always reset.
+ const currentValue = isControlled ? value : stateValue;
+ useEarlyEffect(() => {
+ valueRef.current = currentValue;
+ });
+
+ const [, forceUpdate] = useReducer(() => ({}), {});
+ const setValue = useCallback(
+ (value: SetStateAction, ...args: any[]) => {
+ // @ts-ignore - TS doesn't know that T cannot be a function.
+ const newValue = typeof value === 'function' ? value(valueRef.current) : value;
+ if (!Object.is(valueRef.current, newValue)) {
+ // Update the ref so that the next setState callback has the most recent value.
+ valueRef.current = newValue;
+
+ setStateValue(newValue);
+
+ // Always trigger a re-render, even when controlled, so that the layout effect above runs to reset the value.
+ forceUpdate();
+
+ // Trigger onChange. Note that if setState is called multiple times in a single event,
+ // onChange will be called for each one instead of only once.
+ onChange?.(newValue, ...args);
+ }
+ },
+ [onChange]
+ );
+
+ return [currentValue, setValue];
+}
diff --git a/packages/gitbook/src/components/primitives/Input.tsx b/packages/gitbook/src/components/primitives/Input.tsx
new file mode 100644
index 0000000000..5f5a819cee
--- /dev/null
+++ b/packages/gitbook/src/components/primitives/Input.tsx
@@ -0,0 +1,317 @@
+'use client';
+
+import { tString, useLanguage } from '@/intl/client';
+import { tcls } from '@/lib/tailwind';
+import { Icon, type IconName } from '@gitbook/icons';
+import React, { type ReactNode } from 'react';
+import { useControlledState } from '../hooks/useControlledState';
+import { Button, type ButtonProps } from './Button';
+import { KeyboardShortcut, type KeyboardShortcutProps } from './KeyboardShortcut';
+
+type CustomInputProps = {
+ label: string;
+ inline?: boolean;
+ leading?: IconName | React.ReactNode;
+ trailing?: React.ReactNode;
+ sizing?: 'medium' | 'large'; // The `size` prop is already taken by the HTML input element.
+ containerRef?: React.RefObject;
+ /**
+ * A submit button, shown to the right of the input.
+ */
+ submitButton?: boolean | ButtonProps;
+ /**
+ * A message to be shown to the right of the input when the value has been submitted.
+ */
+ submitMessage?: string | ReactNode;
+ /**
+ * A clear button, shown to the left of the input.
+ */
+ clearButton?: boolean | ButtonProps;
+ /**
+ * A keyboard shortcut, shown to the right of the input.
+ */
+ keyboardShortcut?: boolean | KeyboardShortcutProps;
+
+ onSubmit?: (value: string | number | readonly string[] | undefined) => void;
+ resize?: boolean;
+};
+
+export type InputProps = CustomInputProps &
+ (
+ | ({ multiline?: false } & React.InputHTMLAttributes)
+ | ({ multiline: true } & React.TextareaHTMLAttributes)
+ );
+
+type InputElement = HTMLInputElement | HTMLTextAreaElement;
+
+/**
+ * Input component with core functionality (submitting, clearing, validating) and shared styles.
+ */
+export const Input = React.forwardRef((props, passedRef) => {
+ const {
+ // Custom props
+ multiline,
+ sizing = 'medium',
+ inline = false,
+ leading,
+ trailing,
+ className,
+ clearButton,
+ submitButton,
+ submitMessage,
+ label,
+ keyboardShortcut,
+ onSubmit,
+ containerRef,
+ resize = false,
+ // HTML attributes we need to read
+ value: passedValue,
+ 'aria-label': ariaLabel,
+ 'aria-busy': ariaBusy,
+ placeholder,
+ disabled,
+ onChange,
+ onKeyDown,
+ maxLength,
+ minLength,
+ // Rest are HTML attributes to pass through
+ ...htmlProps
+ } = props;
+
+ const [value, setValue] = useControlledState(passedValue, passedValue ?? '');
+ const [submitted, setSubmitted] = React.useState(false);
+ const [height, setHeight] = React.useState();
+ const inputRef = React.useRef(null);
+ const ref = (passedRef as React.RefObject) ?? inputRef;
+
+ const language = useLanguage();
+ const hasValue = value.toString().trim().length > 0;
+ const hasValidValue =
+ hasValue &&
+ (maxLength ? value.toString().length <= maxLength : true) &&
+ (minLength ? value.toString().length >= minLength : true);
+
+ const sizes = {
+ medium: {
+ container: `${multiline ? 'p-2' : 'px-4 py-2'} gap-2 circular-corners:rounded-3xl rounded-corners:rounded-xl`,
+ input: '-m-2 p-2',
+ gap: 'gap-2',
+ },
+ large: {
+ container: `${multiline ? 'p-3' : 'px-6 py-3 '} gap-3 circular-corners:rounded-3xl rounded-corners:rounded-xl`,
+ input: '-m-3 p-3',
+ gap: 'gap-3',
+ },
+ };
+
+ const handleChange = (event: React.ChangeEvent) => {
+ const newValue = event.target.value;
+ setValue(newValue);
+ onChange?.(event as React.ChangeEvent);
+
+ // Reset submitted state when user edits the value to allow re-submission
+ if (submitted) {
+ setSubmitted(false);
+ }
+
+ if (multiline && resize && ref.current) {
+ // TODO: replace with `field-sizing: content` when more broadly supported. https://caniuse.com/?search=field-sizing
+ // Reset the height to auto, then set it to the scroll height. If we don't reset, the height will only ever grow.
+ setHeight(ref.current.scrollHeight);
+ }
+ };
+
+ const handleClear = () => {
+ if (!ref.current) return;
+ setValue('');
+ };
+
+ const handleClick = () => {
+ ref.current?.focus();
+ };
+
+ const handleSubmit = () => {
+ if (hasValue && onSubmit) {
+ onSubmit(value);
+ setSubmitted(true);
+ setValue('');
+ }
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ onKeyDown?.(event as React.KeyboardEvent);
+ // If the user wants to handle the keydown by itself, we let him do it.
+ if (event.defaultPrevented) return;
+
+ if (event.key === 'Enter' && !event.shiftKey && hasValue) {
+ event.preventDefault();
+ handleSubmit();
+ } else if (event.key === 'Escape') {
+ event.preventDefault();
+ event.currentTarget.blur();
+ }
+ };
+
+ const inputClassName = tcls(
+ 'peer -m-2 max-h-64 grow resize-none text-left outline-none placeholder:text-tint/8 aria-busy:cursor-progress',
+ sizes[sizing].input
+ );
+
+ const inputProps = {
+ className: inputClassName,
+ value: value,
+ onKeyDown: handleKeyDown,
+ 'aria-busy': ariaBusy,
+ onChange: handleChange,
+ 'aria-label': ariaLabel ?? label,
+ placeholder: placeholder ?? label,
+ disabled: disabled,
+ maxLength: maxLength,
+ minLength: minLength,
+ style: {
+ height: multiline && resize && hasValue && height ? `${height}px` : undefined,
+ },
+ };
+
+ const Tag: React.ElementType = inline ? 'span' : 'div';
+
+ return (
+ {
+ if (event.key === 'Enter' || event.key === ' ') {
+ handleClick();
+ }
+ }}
+ ref={containerRef}
+ >
+
+ {leading ? (
+
+ {typeof leading === 'string' ? (
+
+ ) : (
+ leading
+ )}
+
+ ) : null}
+ {clearButton ? (
+
+ ) : null}
+
+ {multiline ? (
+
+ {trailing || submitButton || maxLength ? (
+
+ {trailing}
+ {maxLength && !submitted && value.toString().length > maxLength * 0.8 ? (
+ = maxLength
+ ? 'text-danger-subtle'
+ : 'text-tint-subtle'
+ )}
+ >
+ {value.toString().length} / {maxLength}
+
+ ) : null}
+ {submitted && submitMessage ? (
+ typeof submitMessage === 'string' ? (
+
+
+ {submitMessage}
+
+ ) : (
+ submitMessage
+ )
+ ) : submitButton ? (
+
+ ) : null}
+
+ ) : null}
+
+ );
+});
diff --git a/packages/gitbook/src/components/primitives/KeyboardShortcut.tsx b/packages/gitbook/src/components/primitives/KeyboardShortcut.tsx
index 49705139ac..a6104bf730 100644
--- a/packages/gitbook/src/components/primitives/KeyboardShortcut.tsx
+++ b/packages/gitbook/src/components/primitives/KeyboardShortcut.tsx
@@ -1,9 +1,13 @@
'use client';
-import { type ClassValue, tcls } from '@/lib/tailwind';
+import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import * as React from 'react';
+export type KeyboardShortcutProps = {
+ keys: string[];
+} & React.HTMLAttributes;
+
function getOperatingSystem() {
const platform = navigator.platform.toLowerCase();
@@ -13,7 +17,7 @@ function getOperatingSystem() {
return 'win';
}
-export function KeyboardShortcut(props: { keys: string[]; className?: ClassValue }) {
+export function KeyboardShortcut(props: KeyboardShortcutProps) {
const { keys, className } = props;
const [operatingSystem, setOperatingSystem] = React.useState(null);
@@ -41,7 +45,7 @@ export function KeyboardShortcut(props: { keys: string[]; className?: ClassValue
break;
case 'enter':
- element = ;
+ element = ;
break;
}
return (
diff --git a/packages/gitbook/src/components/primitives/index.ts b/packages/gitbook/src/components/primitives/index.ts
index 83e4ac69ed..74927a29b9 100644
--- a/packages/gitbook/src/components/primitives/index.ts
+++ b/packages/gitbook/src/components/primitives/index.ts
@@ -13,3 +13,4 @@ export * from './Popover';
export * from './LoadingStateProvider';
export * from './HoverCard';
export * from './DropdownMenu';
+export * from './Input';