Create <Input> component (#3857)

This commit is contained in:
Zeno Kapitein
2025-12-16 19:43:43 +01:00
committed by GitHub
parent bd11c0d21b
commit f478ddc2ed
10 changed files with 510 additions and 204 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add Input component
@@ -201,7 +201,6 @@ export function AIChatBody(props: {
const { chatController, chat, suggestions, greeting } = props;
const { trademark } = useAI().config;
const [input, setInput] = React.useState('');
const language = useLanguage();
const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting
@@ -271,13 +270,10 @@ export function AIChatBody(props: {
{chat.error ? <AIChatError chatController={chatController} /> : null}
<AIChatInput
value={input}
onChange={setInput}
loading={chat.loading}
disabled={chat.loading || chat.error}
onSubmit={() => {
chatController.postMessage({ message: input });
setInput('');
onSubmit={(value) => {
chatController.postMessage({ message: value });
}}
/>
</div>
@@ -1,38 +1,26 @@
import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { useEffect, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useAIChatState } from '../AI/useAIChat';
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
import { Input } from '../primitives/Input';
export function AIChatInput(props: {
value: string;
disabled?: boolean;
/**
* When true, the input is disabled
*/
loading: boolean;
onChange: (value: string) => void;
onSubmit: (value: string) => void;
}) {
const { value, onChange, onSubmit, disabled, loading } = props;
const { onSubmit, disabled, loading } = props;
const language = useLanguage();
const chat = useAIChatState();
const inputRef = useRef<HTMLTextAreaElement>(null);
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const textarea = event.currentTarget;
onChange(textarea.value);
// Auto-resize
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
};
useEffect(() => {
if (chat.opened && !disabled && !loading) {
// Add a small delay to ensure the input is rendered before focusing
@@ -57,57 +45,33 @@ export function AIChatInput(props: {
);
return (
<div className="depth-subtle:has-[textarea:focus]:-translate-y-px relative flex animate-blur-in-slow flex-col overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-xl bg-tint-base/9 depth-subtle:shadow-sm shadow-tint/6 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:shadow-primary-subtle has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base dark:shadow-tint-1">
<textarea
ref={inputRef}
disabled={disabled || loading}
data-loading={loading}
data-testid="ai-chat-input"
className={tcls(
'resize-none',
'focus:outline-hidden',
'focus:ring-0',
'w-full',
'px-3',
'py-3',
'pb-12',
'h-auto',
'bg-transparent',
'peer',
'max-h-64',
'placeholder:text-tint/8',
'transition-colors',
'disabled:bg-tint-subtle',
'delay-300',
'disabled:delay-0',
'disabled:cursor-not-allowed',
'data-[loading=true]:cursor-progress',
'data-[loading=true]:opacity-50'
)}
value={value}
rows={1}
placeholder={tString(language, 'ai_chat_input_placeholder')}
onChange={handleInput}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.currentTarget.blur();
return;
}
if (event.key === 'Enter' && !event.shiftKey && value.trim()) {
event.preventDefault();
event.currentTarget.style.height = 'auto';
onSubmit(value);
}
}}
/>
{!disabled ? (
<div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
<KeyboardShortcut keys={['mod', 'i']} className="bg-tint-base" />
</div>
) : null}
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 py-2">
<Input
data-testid="ai-chat-input"
name="ai-chat-input"
multiline
resize
sizing="large"
label="Assistant chat input"
placeholder={tString(language, 'ai_chat_input_placeholder')}
onSubmit={(val) => onSubmit(val as string)}
submitButton={{
label: tString(language, 'send'),
}}
className="animate-blur-in-slow bg-tint-base/9 backdrop-blur-lg contrast-more:bg-tint-base"
rows={1}
maxLength={2048}
keyboardShortcut={
!disabled && !loading
? {
keys: ['mod', 'i'],
className: 'bg-tint-base group-focus-within/input:hidden',
}
: undefined
}
disabled={disabled || loading}
aria-busy={loading}
ref={inputRef}
trailing={
<HoverCardRoot openDelay={500}>
<HoverCard
className="max-w-xs bg-tint p-2 text-sm text-tint"
@@ -135,7 +99,8 @@ export function AIChatInput(props: {
</div>
</HoverCard>
<HoverCardTrigger>
<div className="flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2.5 py-1.5 text-tint/7 text-xs transition-all hover:bg-tint">
{/* Negative margin to compensate for Input's padding, so the badge appears flush with the cursor */}
<div className="-ml-1 flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2.5 py-1.5 text-tint/7 text-xs transition-all hover:bg-tint">
<span className="-ml-1 circular-corners:rounded-2xl rounded-corners:rounded-sm bg-tint-11/7 px-1 py-0.5 font-mono font-semibold text-[0.65rem] text-contrast-tint-11 leading-none">
{t(language, 'ai_chat_context_badge')}
</span>{' '}
@@ -146,14 +111,7 @@ export function AIChatInput(props: {
</div>
</HoverCardTrigger>
</HoverCardRoot>
<Button
label={tString(language, 'send')}
size="medium"
className="ml-auto"
disabled={disabled || !value.trim()}
onClick={() => onSubmit(value)}
/>
</div>
</div>
}
/>
);
}
@@ -156,8 +156,10 @@
/** Text input */
.contentkit-textinput {
@apply w-full rounded border border-tint text-tint-strong placeholder:text-tint flex resize-none flex-1 px-2 py-1.5 text-sm bg-transparent whitespace-pre-line;
@apply focus:outline-primary focus:border-primary;
@apply w-full circular-corners:rounded-3xl ring-primary-hover rounded-corners:rounded-lg border border-tint text-tint-strong transition-all placeholder:text-tint/8 flex resize-none flex-1 px-2 py-1.5 text-sm bg-tint-base whitespace-pre-line;
@apply shadow-tint/6 depth-subtle:focus-within:-translate-y-px depth-subtle:shadow-sm depth-subtle:focus-within:shadow-lg dark:shadow-tint-1;
@apply focus:border-primary-hover focus:shadow-primary-subtle focus:ring-2 hover:border-tint-hover focus:hover:border-primary-hover;
@apply disabled:cursor-not-allowed disabled:border-tint-subtle disabled:bg-tint-subtle;
}
/** Form */
@@ -6,10 +6,10 @@ import React, { type ButtonHTMLAttributes } from 'react';
import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { useTrackEvent } from '../Insights';
import { Button, ButtonGroup } from '../primitives';
import { Button, ButtonGroup, Input } from '../primitives';
const MIN_COMMENT_LENGTH = 3;
const MAX_COMMENT_LENGTH = 512;
/**
@@ -24,7 +24,6 @@ export function PageFeedbackForm(props: {
const trackEvent = useTrackEvent();
const inputRef = React.useRef<HTMLTextAreaElement>(null);
const [rating, setRating] = React.useState<PageFeedbackRating>();
const [comment, setComment] = React.useState('');
const [submitted, setSubmitted] = React.useState(false);
const onSubmitRating = (rating: PageFeedbackRating) => {
@@ -86,43 +85,21 @@ export function PageFeedbackForm(props: {
</ButtonGroup>
</div>
{rating ? (
<div className="flex flex-col gap-2">
{!submitted ? (
<>
<textarea
ref={inputRef}
name="comment"
className="mx-0.5 max-h-40 min-h-16 grow rounded-sm straight-corners:rounded-none bg-tint-base p-2 ring-1 ring-tint ring-inset placeholder:text-sm placeholder:text-tint contrast-more:ring-tint-12 contrast-more:placeholder:text-tint-strong"
placeholder={tString(languages, 'was_this_helpful_comment')}
aria-label={tString(languages, 'was_this_helpful_comment')}
onChange={(e) => setComment(e.target.value)}
value={comment}
rows={3}
maxLength={MAX_COMMENT_LENGTH}
/>
<div className="flex items-center justify-between gap-4">
<Button
size="small"
onClick={() => onSubmitComment(rating, comment)}
label={tString(languages, 'submit')}
/>
{comment.length > MAX_COMMENT_LENGTH * 0.8 ? (
<span
className={
comment.length === MAX_COMMENT_LENGTH
? 'text-red-500'
: ''
}
>
{comment.length} / {MAX_COMMENT_LENGTH}
</span>
) : null}
</div>
</>
) : (
<p>{t(languages, 'was_this_helpful_thank_you')}</p>
)}
</div>
<Input
ref={inputRef}
label={tString(languages, 'was_this_helpful_comment')}
multiline
submitButton
rows={3}
name="page-feedback-comment"
onSubmit={(comment) => onSubmitComment(rating, comment as string)}
maxLength={MAX_COMMENT_LENGTH}
minLength={MIN_COMMENT_LENGTH}
disabled={submitted}
submitMessage={tString(languages, 'was_this_helpful_thank_you')}
className="animate-blur-in"
resize
/>
) : null}
</div>
);
@@ -1,13 +1,9 @@
'use client';
import React from 'react';
import { useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { Button, variantClasses } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
import { useClassnames } from '../primitives/StyleProvider';
import { Input } from '../primitives';
interface SearchInputProps {
onChange: (value: string) => void;
@@ -20,14 +16,11 @@ interface SearchInputProps {
children?: React.ReactNode;
}
// Size classes for medium size button
const sizeClasses = ['text-sm', 'px-3.5', 'py-1.5', '@2xl:circular-corners:px-4'];
/**
* Input to trigger search.
*/
export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
function SearchInput(props, ref) {
function SearchInput(props, containerRef) {
const {
onChange,
onKeyDown,
@@ -42,7 +35,6 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
const inputRef = useRef<HTMLInputElement>(null);
const language = useLanguage();
const buttonStyles = useClassnames(['ButtonStyles']);
useEffect(() => {
if (isOpen) {
@@ -58,74 +50,45 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
}, [isOpen, value]);
return (
<div className={tcls('relative flex size-9 grow', className)}>
{/* 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. */}
<div
ref={ref}
onClick={onFocus}
className={tcls(
// Apply button styles
buttonStyles,
variantClasses.header,
sizeClasses,
// Additional custom styles
'has-[input:focus]:-translate-y-px h-9 grow @2xl:cursor-text cursor-pointer px-2.5 has-[input:focus]:bg-tint-base has-[input:focus]:depth-subtle:shadow-lg has-[input:focus]:depth-subtle:shadow-primary-subtle has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-primary-hover',
'theme-bold:border-header-link/3 has-[input:focus-visible]:theme-bold:border-header-link/5 has-[input:focus-visible]:theme-bold:bg-header-link/3 has-[input:focus-visible]:theme-bold:ring-header-link/5',
'theme-bold:before:absolute theme-bold:before:inset-0 theme-bold:before:bg-header-background/7 theme-bold:before:backdrop-blur-xl ', // Special overlay to make the transparent colors of theme-bold visible.
'@max-2xl:absolute relative @max-2xl:right-0 z-30 max-w-none shrink grow justify-start',
isOpen ? '@max-2xl:w-56' : '@max-2xl:w-[38px]'
)}
>
{value && isOpen ? (
<Button
variant="blank"
label={tString(language, 'clear')}
size="medium"
iconOnly
icon="circle-xmark"
className="-ml-1.5 -mr-1 animate-scale-in px-1.5 theme-bold:text-header-link theme-bold:hover:bg-header-link/3"
onClick={() => {
onChange('');
inputRef.current?.focus();
}}
/>
) : (
<div className="relative flex @max-2xl:size-9.5 grow">
<Input
data-testid="search-input"
name="search-input"
ref={inputRef}
containerRef={containerRef as React.RefObject<HTMLDivElement | null>}
sizing="medium"
label={tString(language, withAI ? 'search_or_ask' : 'search')}
className="@max-2xl:absolute inset-y-0 right-0 z-30 @max-2xl:max-w-9.5 grow theme-bold:border-header-link/4 theme-bold:bg-header-link/1 @max-2xl:px-2.5 theme-bold:text-header-link theme-bold:shadow-none! theme-bold:backdrop-blur-xl @max-2xl:focus-within:w-56 @max-2xl:focus-within:max-w-[calc(100vw-5rem)] theme-bold:focus-within:border-header-link/6 theme-bold:focus-within:ring-header-link/5 theme-bold:hover:border-header-link/5 @max-2xl:has-[input[aria-expanded=true]]:w-56 @max-2xl:has-[input[aria-expanded=true]]:max-w-[calc(100vw-5rem)] @max-2xl:[&_input]:opacity-0 theme-bold:[&_input]:placeholder:text-header-link/8 @max-2xl:focus-within:[&_input]:opacity-11 @max-2xl:has-[input[aria-expanded=true]]:[&_input]:opacity-11"
placeholder={`${tString(language, withAI ? 'search_or_ask' : 'search')}`}
onFocus={onFocus}
onKeyDown={onKeyDown}
leading={
<Icon
icon="magnifying-glass"
className="size-4 shrink-0 animate-scale-in"
icon="search"
className="size-4 shrink-0 text-tint theme-bold:text-header-link/8"
/>
)}
{children}
<input
{...rest}
type="text"
onFocus={onFocus}
onKeyDown={onKeyDown}
onChange={(event) => onChange(event.target.value)}
value={value}
// We only show "search or ask" if the search input actually handles both search and ask.
placeholder={`${tString(language, withAI ? 'search_or_ask' : 'search')}`}
maxLength={512}
size={10}
data-testid="search-input"
className={tcls(
'peer z-10 min-w-0 grow bg-transparent py-0.5 text-tint-strong theme-bold:text-header-link outline-hidden transition-[width] duration-300 contain-paint placeholder:text-tint theme-bold:placeholder:text-current theme-bold:placeholder:opacity-7',
isOpen ? '' : '@max-2xl:opacity-0'
)}
role="combobox"
autoComplete="off"
aria-autocomplete="list"
aria-haspopup="listbox"
aria-expanded={value && isOpen ? 'true' : 'false'}
// Forward
ref={inputRef}
/>
<KeyboardShortcut
keys={isOpen ? ['esc'] : ['mod', 'k']}
className="last:-mr-1 theme-bold:border-header-link/5 theme-bold:bg-header-background theme-bold:text-header-link"
/>
</div>
}
onChange={(event) => {
onChange(event.target.value);
}}
value={value}
maxLength={512}
autoComplete="off"
aria-autocomplete="list"
aria-haspopup="listbox"
aria-expanded={value && isOpen ? 'true' : 'false'}
clearButton={{
className:
'theme-bold:text-header-link theme-bold:hover:bg-header-link/3 text-base',
}}
keyboardShortcut={{
className:
'theme-bold:border-header-link/4 theme-bold:bg-header-background theme-bold:text-header-link',
keys: isOpen ? ['esc'] : ['mod', 'k'],
}}
{...rest}
type="text"
/>
</div>
);
}
@@ -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<T, C = T>(
value: Exclude<T, undefined>,
defaultValue: Exclude<T, undefined> | undefined,
onChange?: (v: C, ...args: any[]) => void
): [T, (value: SetStateAction<T>, ...args: any[]) => void];
export function useControlledState<T, C = T>(
value: Exclude<T, undefined> | undefined,
defaultValue: Exclude<T, undefined>,
onChange?: (v: C, ...args: any[]) => void
): [T, (value: SetStateAction<T>, ...args: any[]) => void];
export function useControlledState<T, C = T>(
value: T,
defaultValue: T,
onChange?: (v: C, ...args: any[]) => void
): [T, (value: SetStateAction<T>, ...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<T>, ...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];
}
@@ -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<HTMLDivElement | null>;
/**
* 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<HTMLInputElement>)
| ({ multiline: true } & React.TextareaHTMLAttributes<HTMLTextAreaElement>)
);
type InputElement = HTMLInputElement | HTMLTextAreaElement;
/**
* Input component with core functionality (submitting, clearing, validating) and shared styles.
*/
export const Input = React.forwardRef<InputElement, InputProps>((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<number>();
const inputRef = React.useRef<InputElement>(null);
const ref = (passedRef as React.RefObject<HTMLInputElement | HTMLTextAreaElement>) ?? 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<InputElement>) => {
const newValue = event.target.value;
setValue(newValue);
onChange?.(event as React.ChangeEvent<HTMLInputElement & HTMLTextAreaElement>);
// 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<InputElement>) => {
onKeyDown?.(event as React.KeyboardEvent<HTMLInputElement & HTMLTextAreaElement>);
// 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 (
<Tag
className={tcls(
'group/input relative flex min-h-min overflow-hidden border border-tint bg-tint-base align-middle shadow-tint/6 ring-primary-hover transition-all dark:shadow-tint-1',
disabled
? 'cursor-not-allowed border-tint-subtle bg-tint-subtle opacity-7'
: [
'depth-subtle:focus-within:-translate-y-px depth-subtle:hover:-translate-y-px depth-subtle:shadow-xs',
'focus-within:border-primary-hover focus-within:depth-subtle:shadow-lg focus-within:shadow-primary-subtle focus-within:ring-2 hover:cursor-text hover:border-tint-hover depth-subtle:hover:not-focus-within:shadow-md focus-within:hover:border-primary-hover',
],
multiline ? 'flex-col' : 'flex-row',
ariaBusy ? 'cursor-progress' : '',
sizes[sizing].container,
className
)}
onClick={handleClick}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
handleClick();
}
}}
ref={containerRef}
>
<Tag
className={tcls(
'flex grow',
sizes[sizing].gap,
multiline ? 'items-start' : 'items-center'
)}
>
{leading ? (
<Tag
className={tcls(
clearButton && hasValue ? 'group-focus-within/input:hidden' : '',
multiline ? 'my-1.25' : ''
)}
>
{typeof leading === 'string' ? (
<Icon
icon={leading as IconName}
className="size-4 shrink-0 text-tint"
/>
) : (
leading
)}
</Tag>
) : null}
{clearButton ? (
<Button
variant="blank"
size="medium"
label={tString(language, 'clear')}
iconOnly
icon="circle-xmark"
onClick={handleClear}
{...(typeof clearButton === 'object' ? clearButton : {})}
className={tcls(
'-mx-1.5 hidden shrink-0 animate-fade-in p-1.5 text-tint',
multiline ? '-my-0.25' : '-my-1.5',
hasValue ? 'group-focus-within/input:flex' : '',
typeof clearButton === 'object' ? clearButton.className : ''
)}
/>
) : null}
{multiline ? (
<textarea
{...inputProps}
ref={ref as React.RefObject<HTMLTextAreaElement>}
{...(htmlProps as React.TextareaHTMLAttributes<HTMLTextAreaElement>)}
/>
) : (
<input
{...inputProps}
ref={ref as React.RefObject<HTMLInputElement>}
type="text"
size={1} // Size controls the intrinsic width of the input, but we want to control the width with CSS
{...(htmlProps as React.InputHTMLAttributes<HTMLInputElement>)}
/>
)}
{keyboardShortcut !== false ? (
<Tag
className={
multiline ? `absolute top-0 right-0 ${sizes[sizing].container}` : ''
}
>
{typeof keyboardShortcut === 'object' ? (
<KeyboardShortcut {...keyboardShortcut} />
) : onSubmit && !submitted && hasValue ? (
<KeyboardShortcut
keys={['enter']}
className="hidden bg-tint-base group-focus-within/input:flex"
/>
) : null}
</Tag>
) : null}
</Tag>
{trailing || submitButton || maxLength ? (
<Tag className="flex items-center gap-2 empty:hidden">
{trailing}
{maxLength && !submitted && value.toString().length > maxLength * 0.8 ? (
<span
className={tcls(
'shrink-0 animate-fade-in text-xs tabular-nums',
value.toString().length >= maxLength
? 'text-danger-subtle'
: 'text-tint-subtle'
)}
>
{value.toString().length} / {maxLength}
</span>
) : null}
{submitted && submitMessage ? (
typeof submitMessage === 'string' ? (
<Tag className="ml-auto flex animate-fade-in items-center gap-1 p-1.5 text-success-subtle">
<Icon icon="check-circle" className="size-4" />
{submitMessage}
</Tag>
) : (
submitMessage
)
) : submitButton ? (
<Button
variant="primary"
size="medium"
label={tString(language, 'submit')}
onClick={handleSubmit}
icon={multiline ? undefined : 'arrow-right'}
disabled={disabled || !hasValidValue}
iconOnly={!multiline}
className="ml-auto"
{...(typeof submitButton === 'object' ? submitButton : {})}
/>
) : null}
</Tag>
) : null}
</Tag>
);
});
@@ -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<HTMLDivElement>;
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<string | null>(null);
@@ -41,7 +45,7 @@ export function KeyboardShortcut(props: { keys: string[]; className?: ClassValue
break;
case 'enter':
element = <Icon icon="arrow-turn-down-left" className="size-[.75em]" />;
element = <Icon icon="arrow-turn-down-left" className="size-[.9em]" />;
break;
}
return (
@@ -13,3 +13,4 @@ export * from './Popover';
export * from './LoadingStateProvider';
export * from './HoverCard';
export * from './DropdownMenu';
export * from './Input';