Support button actions (#3866)

This commit is contained in:
Zeno Kapitein
2025-12-18 09:29:08 +01:00
committed by GitHub
parent f478ddc2ed
commit ed9e8fe93a
6 changed files with 140 additions and 38 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add support for button actions
+2 -2
View File
@@ -346,7 +346,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.154.0",
"@gitbook/api": "0.155.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -727,7 +727,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.1.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" } }, "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA=="],
"@gitbook/api": ["@gitbook/api@0.154.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-huEgWWa2H+H4ofYEAQl9r49u0gVjDHHWVNJKhd6SgGCfefjNPHkQj9zaky/5DBQzlu/+SDmutWiOtYdrykQO8w=="],
"@gitbook/api": ["@gitbook/api@0.155.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-g4UfJRnej/GZ9pORHcS9mV0kKiJLRvhMlGOeOoEVU/LSIz6s35VDqauSBs7rH00AOaMABDg7uEko8tUSA4aaww=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
+1 -1
View File
@@ -41,7 +41,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.154.0",
"@gitbook/api": "0.155.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -0,0 +1,75 @@
'use client';
import { tString, useLanguage } from '@/intl/client';
import { useAI, useAIChatController, useAIChatState } from '../AI';
import { useSearch } from '../Search';
import { Button, type ButtonProps, Input } from '../primitives';
export function InlineActionButton(
props: { action: 'ask' | 'search'; query?: string } & { buttonProps: ButtonProps } // TODO: Type this properly: Pick<api.DocumentInlineButton, 'action' | 'query'> & { buttonProps: ButtonProps }
) {
const { action, query, buttonProps } = props;
const { assistants } = useAI();
const chatController = useAIChatController();
const chatState = useAIChatState();
const [, setSearchState] = useSearch();
const language = useLanguage();
const handleSubmit = (value: string) => {
if (action === 'ask') {
chatController.open();
if (value ?? query) {
chatController.postMessage({ message: value ?? query });
}
} else if (action === 'search') {
setSearchState((prev) => ({
...prev,
ask: null,
scope: 'default',
query: value ?? query,
open: true,
}));
}
};
const icon =
action === 'ask' && buttonProps.icon === 'gitbook-assistant' && assistants.length > 0
? assistants[0]?.icon
: buttonProps.icon;
if (!query) {
return (
<Input
inline
label={buttonProps.label as string}
sizing="medium"
className="inline-flex max-w-full leading-normal [transition-property:translate,opacity,box-shadow,background,border]"
submitButton={{
label: tString(language, action === 'ask' ? 'send' : 'search'),
}}
clearButton={{
className: 'text-[1em]',
}}
maxLength={action === 'ask' ? 2048 : 512}
disabled={action === 'ask' && chatState.loading}
aria-busy={action === 'ask' && chatState.loading}
leading={icon}
keyboardShortcut={false}
onSubmit={(value) => handleSubmit(value as string)}
containerStyle={{
width: `${buttonProps.label ? buttonProps.label.toString().length + 10 : 20}ch`,
}}
/>
);
}
const label = action === 'ask' ? `Ask "${query}"` : `Search for "${query}"`;
const button = (
<Button {...buttonProps} onClick={() => handleSubmit(query)} label={label}>
{label !== buttonProps.label ? buttonProps.label : null}
</Button>
);
return button;
}
@@ -1,44 +1,65 @@
import { resolveContentRef, resolveContentRefFallback } from '@/lib/references';
import * as api from '@gitbook/api';
import type * as api from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { Button } from '../primitives';
import { Button, type ButtonProps } from '../primitives';
import type { InlineProps } from './Inline';
import { InlineActionButton } from './InlineActionButton';
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
export async function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
const { inline, context } = props;
export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
const { inline } = props;
const resolved = context.contentContext
? await resolveContentRef(inline.data.ref, context.contentContext)
: null;
const buttonProps: ButtonProps = {
label: inline.data.label,
variant: inline.data.kind,
icon: inline.data.icon as IconName | undefined,
};
const href = resolved?.href ?? resolveContentRefFallback(inline.data.ref)?.href;
const ButtonImplementation = () => {
if ('action' in inline.data && 'query' in inline.data.action) {
return (
<InlineActionButton
action={inline.data.action.action}
query={inline.data.action.query ?? ''}
buttonProps={buttonProps}
/>
);
}
return <InlineLinkButton {...props} buttonProps={buttonProps} />;
};
const inlineElement = (
// Set the leading to have some vertical space between adjacent buttons
<span className="inline-button leading-12 [&:has(+.inline-button)]:mr-2">
<Button
href={href}
label={inline.data.label}
// TODO: use a variant specifically for user-defined buttons.
variant={inline.data.kind}
className="leading-normal"
disabled={href === undefined}
icon={inline.data.icon as IconName | undefined}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: api.SiteInsightsLinkPosition.Content,
},
}}
/>
<ButtonImplementation />
</span>
);
if (!resolved) {
return <NotFoundRefHoverCard context={context}>{inlineElement}</NotFoundRefHoverCard>;
}
return inlineElement;
}
export async function InlineLinkButton(
props: InlineProps<api.DocumentInlineButton> & { buttonProps: ButtonProps }
) {
const { inline, context, buttonProps } = props;
if (!('ref' in inline.data)) return;
const resolved =
context.contentContext && inline.data.ref
? await resolveContentRef(inline.data.ref, context.contentContext)
: null;
const href =
resolved?.href ??
(inline.data.ref ? resolveContentRefFallback(inline.data.ref)?.href : undefined);
const button = <Button {...buttonProps} href={href} disabled={href === undefined} />;
if (inline.data.ref && !resolved) {
return <NotFoundRefHoverCard context={context}>{button}</NotFoundRefHoverCard>;
}
return button;
}
@@ -15,6 +15,7 @@ type CustomInputProps = {
trailing?: React.ReactNode;
sizing?: 'medium' | 'large'; // The `size` prop is already taken by the HTML input element.
containerRef?: React.RefObject<HTMLDivElement | null>;
containerStyle?: React.CSSProperties;
/**
* A submit button, shown to the right of the input.
*/
@@ -63,6 +64,7 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
keyboardShortcut,
onSubmit,
containerRef,
containerStyle,
resize = false,
// HTML attributes we need to read
value: passedValue,
@@ -153,7 +155,7 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
};
const inputClassName = tcls(
'peer -m-2 max-h-64 grow resize-none text-left outline-none placeholder:text-tint/8 aria-busy:cursor-progress',
'peer -m-2 max-h-64 grow shrink resize-none text-left outline-none placeholder:text-tint/8 placeholder-shown:text-ellipsis aria-busy:cursor-progress',
sizes[sizing].input
);
@@ -197,10 +199,11 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
}
}}
ref={containerRef}
style={containerStyle}
>
<Tag
className={tcls(
'flex grow',
'flex shrink grow',
sizes[sizing].gap,
multiline ? 'items-start' : 'items-center'
)}
@@ -209,14 +212,12 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
<Tag
className={tcls(
clearButton && hasValue ? 'group-focus-within/input:hidden' : '',
multiline ? 'my-1.25' : ''
multiline ? 'my-1.25' : '',
'text-tint'
)}
>
{typeof leading === 'string' ? (
<Icon
icon={leading as IconName}
className="size-4 shrink-0 text-tint"
/>
<Icon icon={leading as IconName} className="size-4 shrink-0" />
) : (
leading
)}