Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cool-pigs-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": patch
---

Add Input component
104 changes: 32 additions & 72 deletions packages/gitbook/src/components/AIChat/AIChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
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;
Expand All @@ -24,15 +23,6 @@ export function AIChatInput(props: {

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
Expand All @@ -57,57 +47,34 @@ 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')}
onChange={(event) => onChange(event.target.value)}
onSubmit={() => onSubmit(value)}
value={value}
submitButton={{
label: tString(language, 'send'),
}}
className="animate-blur-in-slow bg-tint-base/9 backdrop-blur-lg contrast-more:bg-tint-base"
rows={1}
keyboardShortcut={
!value && !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"
Expand Down Expand Up @@ -146,14 +113,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>
}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
57 changes: 17 additions & 40 deletions packages/gitbook/src/components/PageFeedback/PageFeedbackForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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) => {
Expand Down Expand Up @@ -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>
);
Expand Down
114 changes: 34 additions & 80 deletions packages/gitbook/src/components/Search/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
'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;
Expand All @@ -20,14 +15,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,
Expand All @@ -42,7 +34,6 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
const inputRef = useRef<HTMLInputElement>(null);

const language = useLanguage();
const buttonStyles = useClassnames(['ButtonStyles']);

useEffect(() => {
if (isOpen) {
Expand All @@ -58,74 +49,37 @@ 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();
}}
/>
) : (
<Icon
icon="magnifying-glass"
className="size-4 shrink-0 animate-scale-in"
/>
)}
{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>
<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-background theme-bold:text-header-link theme-bold:shadow-none! @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:bg-header-link/1 theme-bold:focus-within:ring-header-link/5 theme-bold:hover:border-header-link/5 theme-bold:hover:bg-header-link/1 @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 @max-2xl:[&_svg]:ml-0.5 theme-bold:[&_svg]:text-header-link/8"
placeholder={`${tString(language, withAI ? 'search_or_ask' : 'search')}…`}
onFocus={onFocus}
onKeyDown={onKeyDown}
leading="magnifying-glass"
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
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>
);
}
Expand Down
15 changes: 9 additions & 6 deletions packages/gitbook/src/components/Search/useSearchResultsCursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ export function useSearchResultsCursor(props: { query: string; results: ResultTy
}
}, [query]);

React.useEffect(() => {
if (results.length > 0) {
// Auto-focus the first result
setCursor(0);
}
}, [results]);
// TODO: `results` is getting updated too often, causing the cursor to reset to 0 for every render.
// Reimplement this once we have fixed that issue.

// React.useEffect(() => {
// if (results.length > 0) {
// // Auto-focus the first result
// setCursor(0);
// }
// }, [results]);

const moveBy = React.useCallback(
(delta: number) => {
Expand Down
Loading
Loading