Skip to content

Commit edfd1cd

Browse files
committed
Initial
1 parent 44feb3b commit edfd1cd

File tree

3 files changed

+273
-63
lines changed

3 files changed

+273
-63
lines changed

packages/gitbook/src/components/AIChat/AIChatInput.tsx

Lines changed: 119 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { t, tString, useLanguage } from '@/intl/client';
2-
import { tcls } from '@/lib/tailwind';
32
import { Icon } from '@gitbook/icons';
43
import { useEffect, useRef } from 'react';
54
import { useHotkeys } from 'react-hotkeys-hook';
65
import { useAIChatState } from '../AI/useAIChat';
7-
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
6+
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
7+
import { Input } from '../primitives/Input';
88
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
99

1010
export function AIChatInput(props: {
@@ -57,57 +57,24 @@ export function AIChatInput(props: {
5757
);
5858

5959
return (
60-
<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">
61-
<textarea
62-
ref={inputRef}
63-
disabled={disabled || loading}
64-
data-loading={loading}
65-
data-testid="ai-chat-input"
66-
className={tcls(
67-
'resize-none',
68-
'focus:outline-hidden',
69-
'focus:ring-0',
70-
'w-full',
71-
'px-3',
72-
'py-3',
73-
'pb-12',
74-
'h-auto',
75-
'bg-transparent',
76-
'peer',
77-
'max-h-64',
78-
'placeholder:text-tint/8',
79-
'transition-colors',
80-
'disabled:bg-tint-subtle',
81-
'delay-300',
82-
'disabled:delay-0',
83-
'disabled:cursor-not-allowed',
84-
'data-[loading=true]:cursor-progress',
85-
'data-[loading=true]:opacity-50'
86-
)}
87-
value={value}
88-
rows={1}
89-
placeholder={tString(language, 'ai_chat_input_placeholder')}
90-
onChange={handleInput}
91-
onKeyDown={(event) => {
92-
if (event.key === 'Escape') {
93-
event.preventDefault();
94-
event.currentTarget.blur();
95-
return;
96-
}
97-
98-
if (event.key === 'Enter' && !event.shiftKey && value.trim()) {
99-
event.preventDefault();
100-
event.currentTarget.style.height = 'auto';
101-
onSubmit(value);
102-
}
103-
}}
104-
/>
105-
{!disabled ? (
106-
<div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
60+
<Input
61+
multiline
62+
label="Assistant input"
63+
placeholder={tString(language, 'ai_chat_input_placeholder')}
64+
onChange={handleInput}
65+
onSubmit={() => onSubmit(value)}
66+
value={value}
67+
leading="magnifying-glass"
68+
submitButton={{
69+
label: tString(language, 'send'),
70+
}}
71+
keyboardShortcut={
72+
<div className="peer-focus:hidden">
10773
<KeyboardShortcut keys={['mod', 'i']} className="bg-tint-base" />
10874
</div>
109-
) : null}
110-
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 py-2">
75+
}
76+
ref={inputRef}
77+
trailing={
11178
<HoverCardRoot openDelay={500}>
11279
<HoverCard
11380
className="max-w-xs bg-tint p-2 text-sm text-tint"
@@ -135,7 +102,7 @@ export function AIChatInput(props: {
135102
</div>
136103
</HoverCard>
137104
<HoverCardTrigger>
138-
<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">
105+
<div className="-ml-2 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">
139106
<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">
140107
{t(language, 'ai_chat_context_badge')}
141108
</span>{' '}
@@ -146,14 +113,105 @@ export function AIChatInput(props: {
146113
</div>
147114
</HoverCardTrigger>
148115
</HoverCardRoot>
149-
<Button
150-
label={tString(language, 'send')}
151-
size="medium"
152-
className="ml-auto"
153-
disabled={disabled || !value.trim()}
154-
onClick={() => onSubmit(value)}
155-
/>
156-
</div>
157-
</div>
116+
}
117+
/>
118+
// <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">
119+
// {/* <textarea
120+
// ref={inputRef}
121+
// disabled={disabled || loading}
122+
// data-loading={loading}
123+
// data-testid="ai-chat-input"
124+
// className={tcls(
125+
// 'resize-none',
126+
// 'focus:outline-hidden',
127+
// 'focus:ring-0',
128+
// 'w-full',
129+
// 'px-3',
130+
// 'py-3',
131+
// 'pb-12',
132+
// 'h-auto',
133+
// 'bg-transparent',
134+
// 'peer',
135+
// 'max-h-64',
136+
// 'placeholder:text-tint/8',
137+
// 'transition-colors',
138+
// 'disabled:bg-tint-subtle',
139+
// 'delay-300',
140+
// 'disabled:delay-0',
141+
// 'disabled:cursor-not-allowed',
142+
// 'data-[loading=true]:cursor-progress',
143+
// 'data-[loading=true]:opacity-50'
144+
// )}
145+
// value={value}
146+
// rows={1}
147+
// placeholder={tString(language, 'ai_chat_input_placeholder')}
148+
// onChange={handleInput}
149+
// onKeyDown={(event) => {
150+
// if (event.key === 'Escape') {
151+
// event.preventDefault();
152+
// event.currentTarget.blur();
153+
// return;
154+
// }
155+
156+
// if (event.key === 'Enter' && !event.shiftKey && value.trim()) {
157+
// event.preventDefault();
158+
// event.currentTarget.style.height = 'auto';
159+
// onSubmit(value);
160+
// }
161+
// }}
162+
// /> */}
163+
// {/* {!disabled ? (
164+
// <div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
165+
// <KeyboardShortcut keys={['mod', 'i']} className="bg-tint-base" />
166+
// </div>
167+
// ) : null} */}
168+
// <div className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 py-2">
169+
// <HoverCardRoot openDelay={500}>
170+
// <HoverCard
171+
// className="max-w-xs bg-tint p-2 text-sm text-tint"
172+
// arrow={{ className: 'fill-tint-3' }}
173+
// >
174+
// <div className="flex flex-col gap-3 p-2">
175+
// <p className="font-semibold">
176+
// {t(language, 'ai_chat_context_description')}
177+
// </p>
178+
// <ul className="flex flex-col gap-2">
179+
// <li className="flex items-center gap-2">
180+
// <Icon icon="memo" className="size-3.5 opacity-7" />
181+
// {t(language, 'ai_chat_context_pages_youve_read')}
182+
// </li>
183+
// <li className="flex items-center gap-2">
184+
// <Icon icon="user" className="size-3.5 opacity-7" />
185+
// {t(language, 'ai_chat_context_info_provided_by_the_site')}
186+
// </li>
187+
// <li className="flex items-center gap-2">
188+
// <Icon icon="message-question" className="size-3.5 opacity-7" />
189+
// {t(language, 'ai_chat_context_previous_messages')}
190+
// </li>
191+
// </ul>
192+
// <p>{t(language, 'ai_chat_context_disclaimer')}</p>
193+
// </div>
194+
// </HoverCard>
195+
// <HoverCardTrigger>
196+
// <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">
197+
// <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">
198+
// {t(language, 'ai_chat_context_badge')}
199+
// </span>{' '}
200+
// <span className="leading-none">
201+
// {t(language, 'ai_chat_context_title')}
202+
// </span>
203+
// <Icon icon="question-circle" className="size-3 shrink-0" />
204+
// </div>
205+
// </HoverCardTrigger>
206+
// </HoverCardRoot>
207+
// <Button
208+
// label={tString(language, 'send')}
209+
// size="medium"
210+
// className="ml-auto"
211+
// disabled={disabled || !value.trim()}
212+
// onClick={() => onSubmit(value)}
213+
// />
214+
// </div>
215+
// </div>
158216
);
159217
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
'use client';
2+
3+
import { tString, useLanguage } from '@/intl/client';
4+
import { tcls } from '@/lib/tailwind';
5+
import { Icon, type IconName } from '@gitbook/icons';
6+
import React, { type ReactNode } from 'react';
7+
import { Button, type ButtonProps } from './Button';
8+
import { KeyboardShortcut } from './KeyboardShortcut';
9+
10+
export type InputProps = {
11+
label: string;
12+
leading?: IconName | React.ReactNode;
13+
trailing?: React.ReactNode;
14+
/**
15+
* When true, a submit button will be shown to the right of the input.
16+
*/
17+
submitButton?: boolean | ButtonProps;
18+
keyboardShortcut?: boolean | ReactNode;
19+
onSubmit: (value: string | number | readonly string[] | undefined) => void;
20+
} & (
21+
| (React.InputHTMLAttributes<HTMLInputElement> & { multiline?: false })
22+
| (React.TextareaHTMLAttributes<HTMLTextAreaElement> & { multiline: true })
23+
);
24+
25+
/**
26+
* Input base component with core functionality and shared styles.
27+
*/
28+
export const Input = React.forwardRef<HTMLInputElement | HTMLTextAreaElement, InputProps>(
29+
(props, passedRef) => {
30+
const {
31+
multiline,
32+
value: initialValue,
33+
leading,
34+
trailing,
35+
className,
36+
submitButton,
37+
label,
38+
'aria-label': ariaLabel,
39+
placeholder,
40+
keyboardShortcut,
41+
onSubmit,
42+
onChange,
43+
...rest
44+
} = props;
45+
46+
const [value, setValue] = React.useState(initialValue ?? '');
47+
const internalRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>(null);
48+
const ref = passedRef ?? internalRef;
49+
50+
const language = useLanguage();
51+
const hasValue = value.toString().trim();
52+
53+
React.useEffect(() => {
54+
setValue(initialValue ?? '');
55+
}, [initialValue]);
56+
57+
const handleClick = () => {
58+
const element = internalRef.current;
59+
if (element) {
60+
element.focus();
61+
}
62+
};
63+
64+
const handleSubmit = () => {
65+
if (hasValue) {
66+
onSubmit(value);
67+
}
68+
};
69+
70+
const input = (
71+
<input
72+
className="peer grow resize-none overflow-visible outline-none placeholder:text-tint"
73+
ref={ref as React.Ref<HTMLInputElement>}
74+
value={value}
75+
onKeyDown={(event) => {
76+
if (event.key === 'Enter') {
77+
event.preventDefault();
78+
handleSubmit();
79+
}
80+
}}
81+
onChange={(event) => {
82+
setValue(event.target.value);
83+
onChange?.(
84+
event as React.ChangeEvent<HTMLInputElement> &
85+
React.ChangeEvent<HTMLTextAreaElement>
86+
);
87+
}}
88+
aria-label={ariaLabel ?? label}
89+
placeholder={placeholder ? placeholder : label}
90+
{...(rest as React.InputHTMLAttributes<HTMLInputElement>)}
91+
/>
92+
);
93+
94+
return (
95+
<div
96+
className={tcls(
97+
'relative flex max-h-64 min-h-min gap-2 overflow-auto circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint-subtle bg-tint-base p-3 transition-[outline,border] focus-within:outline-2 focus-within:outline-primary-hover hover:cursor-text hover:border-tint-hover',
98+
multiline ? 'resize-y flex-col' : 'flex-row',
99+
className
100+
)}
101+
onClick={handleClick}
102+
onKeyDown={(event) => {
103+
if (event.key === 'Enter' || event.key === ' ') {
104+
handleClick();
105+
}
106+
}}
107+
>
108+
<div
109+
className={tcls('flex grow gap-2', multiline ? 'items-start' : 'items-center')}
110+
>
111+
{leading ? (
112+
typeof leading === 'string' ? (
113+
<Icon icon={leading as IconName} className="my-0.5 size-4 shrink-0" />
114+
) : (
115+
leading
116+
)
117+
) : null}
118+
{multiline ? <textarea {...input.props} /> : input}
119+
{keyboardShortcut !== false ? (
120+
typeof keyboardShortcut === 'object' ? (
121+
keyboardShortcut
122+
) : hasValue ? (
123+
<KeyboardShortcut keys={['ENTER']} />
124+
) : null
125+
) : null}
126+
</div>
127+
<div className="flex items-center gap-2">
128+
{trailing ? trailing : null}
129+
{submitButton ? (
130+
<Button
131+
variant="primary"
132+
size="medium"
133+
label={tString(language, 'submit')}
134+
onClick={handleSubmit}
135+
icon={multiline ? undefined : 'arrow-right'}
136+
disabled={!hasValue}
137+
iconOnly={!multiline}
138+
className="ml-auto"
139+
{...(typeof submitButton === 'object'
140+
? { ...submitButton }
141+
: undefined)}
142+
/>
143+
) : null}
144+
</div>
145+
</div>
146+
);
147+
}
148+
);

packages/gitbook/src/components/primitives/KeyboardShortcut.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
'use client';
22

3-
import { type ClassValue, tcls } from '@/lib/tailwind';
3+
import { tcls } from '@/lib/tailwind';
44
import { Icon } from '@gitbook/icons';
55
import * as React from 'react';
66

7+
export type KeyboardShortcutProps = {
8+
keys: string[];
9+
} & React.HTMLAttributes<HTMLDivElement>;
10+
711
function getOperatingSystem() {
812
const platform = navigator.platform.toLowerCase();
913

@@ -13,7 +17,7 @@ function getOperatingSystem() {
1317
return 'win';
1418
}
1519

16-
export function KeyboardShortcut(props: { keys: string[]; className?: ClassValue }) {
20+
export function KeyboardShortcut(props: KeyboardShortcutProps) {
1721
const { keys, className } = props;
1822

1923
const [operatingSystem, setOperatingSystem] = React.useState<string | null>(null);

0 commit comments

Comments
 (0)