diff --git a/packages/gitbook/src/components/AIChat/AIChatPageBar.tsx b/packages/gitbook/src/components/AIChat/AIChatPageBar.tsx index 9ef2b5f3d..38fcdab13 100644 --- a/packages/gitbook/src/components/AIChat/AIChatPageBar.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatPageBar.tsx @@ -1,6 +1,7 @@ 'use client'; import { readStreamableValue } from 'ai/rsc'; +import { useReducedMotion } from 'motion/react'; import * as React from 'react'; import { useAIChatController, useAIConfig } from '../AI'; @@ -26,12 +27,13 @@ export function AIChatPageBar() { const chatController = useAIChatController(); const trackEvent = useTrackEvent(); const [expanded, setExpanded] = React.useState(false); - const [hasText, setHasText] = React.useState(false); + const [value, setValue] = React.useState(''); + const inputRef = React.useRef(null); // The card's height assumes a one-line input, so the suggestions give way once the visitor types. - const showSuggestions = expanded && !hasText; + const showSuggestions = expanded && !value; - const configuredSuggestions = config.suggestions?.length ? config.suggestions : null; - const [recommended, setRecommended] = React.useState(null); + const configuredSuggestions = config.suggestions?.length ? config.suggestions : undefined; + const [recommended, setRecommended] = React.useState(); React.useEffect(() => { if (configuredSuggestions || !siteSpaceId) { @@ -53,14 +55,16 @@ export function AIChatPageBar() { () => getSuggestedQuestions({ language, - suggestions: configuredSuggestions ?? recommended ?? undefined, + suggestions: configuredSuggestions ?? recommended, }).slice(0, SUGGESTIONS_COUNT), [language, configuredSuggestions, recommended] ); - const typedSuggestion = useTypewriter({ texts: suggestions, enabled: !expanded && ready }); + const typing = !expanded && ready; + useTypewriter({ ref: inputRef, texts: suggestions, enabled: typing }); const ask = (message: string) => { trackEvent({ type: 'ask_trigger_click', trigger: 'page' }); + setValue(''); setExpanded(false); chatController.open('page'); chatController.postMessage({ message }); @@ -68,100 +72,104 @@ export function AIChatPageBar() { return ( // z-30 sits above page content (cards, tables and code blocks go up to z-20) and below sheets and dialogs (z-40). -
-
+ {showSuggestions ? ( + // The input stays in flow on top of the card, so focusing it never moves it; pb-13 leaves its room. +
+ {suggestions.map((question, index) => ( + + ))} +
+ ) : null} + - {showSuggestions ? ( - // The input stays in flow on top of the card, so focusing it never moves it; pb-13 leaves its room. -
- {suggestions.map((question, index) => ( - - ))} -
- ) : null} - { + // Shift+Enter already adds a line in a textarea; Cmd/Ctrl+Enter would otherwise submit. + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + const field = event.currentTarget; + field.setRangeText('\n', field.selectionStart, field.selectionEnd, 'end'); + setValue(field.value); } - maxLength={2048} - keyboardShortcut={false} - submitButton={{ - icon: 'arrow-up', - label: tString(language, 'send'), - iconOnly: true, - className: 'ml-auto -my-1 -mr-1.5', - }} - onValueChange={(value) => setHasText(value.length > 0)} - onKeyDown={(event) => { - // Shift+Enter already adds a line in a textarea; Cmd/Ctrl+Enter would otherwise submit. - if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { - event.preventDefault(); - const field = event.currentTarget; - const end = field.value.length; - field.setRangeText( - '\n', - field.selectionStart ?? end, - field.selectionEnd ?? end, - 'end' - ); - // Lets React's onChange see the edit, so the value and the height stay in sync. - field.dispatchEvent(new Event('input', { bubbles: true })); - } - }} - onFocus={() => setExpanded(true)} - onBlur={(event) => { - if (!event.currentTarget.value) { - setExpanded(false); - } - }} - onSubmit={ask} - className={tcls( - // Keeps the one-line pill layout: textarea and send button side by side. - 'flex-row items-end bg-tint-base/9 px-3 py-2 text-sm circular-corners:rounded-3xl contrast-more:bg-tint-base', - showSuggestions ? null : 'shadow-lg backdrop-blur-lg' - )} - /> -
+ }} + onFocus={() => setExpanded(true)} + onBlur={(event) => { + if (!event.currentTarget.value) { + setExpanded(false); + } + }} + onSubmit={ask} + className={tcls( + // The send button is overlaid, so the textarea spans the full width and scrolls at the right edge. + 'bg-tint-base/9 pl-3 text-sm contrast-more:bg-tint-base [&_textarea]:max-h-32 [&_textarea]:pr-9', + showSuggestions ? null : 'shadow-lg backdrop-blur-lg' + )} + /> +
); } -function useTypewriter({ texts, enabled }: { texts: string[]; enabled: boolean }) { - const [text, setText] = React.useState(''); +// Writes the placeholder straight to the field: a state update per tick would re-render the whole bar. +function useTypewriter({ + ref, + texts, + enabled, +}: { + ref: React.RefObject; + texts: string[]; + enabled: boolean; +}) { + const reduceMotion = useReducedMotion(); React.useEffect(() => { - if (!enabled || texts.length === 0) { + const field = ref.current; + if (!enabled || !field) { return; } - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { - setText(texts[0]!); + if (reduceMotion) { + field.placeholder = texts[0]!; return; } @@ -173,7 +181,7 @@ function useTypewriter({ texts, enabled }: { texts: string[]; enabled: boolean } const tick = () => { const current = texts[index % texts.length]!; length += erasing ? -1 : 1; - setText(current.slice(0, length)); + field.placeholder = current.slice(0, length); let delay = erasing ? 20 : 45; if (!erasing && length >= current.length) { @@ -187,12 +195,10 @@ function useTypewriter({ texts, enabled }: { texts: string[]; enabled: boolean } timeout = setTimeout(tick, delay); }; - setText(''); + field.placeholder = ''; timeout = setTimeout(tick, 600); return () => clearTimeout(timeout); - }, [texts, enabled]); - - return text; + }, [ref, texts, enabled, reduceMotion]); } function fetchRecommendedQuestions(siteSpaceId: string): Promise { diff --git a/packages/gitbook/src/components/primitives/Input.tsx b/packages/gitbook/src/components/primitives/Input.tsx index 7b5df00d9..21e31d4c9 100644 --- a/packages/gitbook/src/components/primitives/Input.tsx +++ b/packages/gitbook/src/components/primitives/Input.tsx @@ -120,7 +120,6 @@ export const Input = React.forwardRef((props, passedRe const [value, setValue] = useControlledState(passedValue, passedValue ?? '', onValueChange); const [submitted, setSubmitted] = React.useState(false); - const [height, setHeight] = React.useState(); const inputRef = React.useRef(null); const ref = (passedRef as React.RefObject) ?? inputRef; @@ -163,14 +162,20 @@ export const Input = React.forwardRef((props, passedRe 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); - } }; + // Runs on every value change, typed or set by the caller, so the height can shrink as well as grow. + React.useLayoutEffect(() => { + const field = ref.current; + if (!multiline || !resize || !field) { + return; + } + // 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. + field.style.height = 'auto'; + field.style.height = value.toString().trim() ? `${field.scrollHeight}px` : ''; + }, [ref, value, multiline, resize]); + const handleClear = () => { if (!ref.current) return; setValue(''); @@ -229,9 +234,6 @@ export const Input = React.forwardRef((props, passedRe readOnly: readOnly, maxLength: maxLength, minLength: minLength, - style: { - height: multiline && resize && hasValue && height ? `${height}px` : undefined, - }, }; // A rich display replaces the control, so it only makes sense when there is nothing to type into.