mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 23:55:20 +00:00
Create <Input> component (#3857)
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user