Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
77 changes: 73 additions & 4 deletions packages/instantsearch.css/src/themes/satellite.scss
Original file line number Diff line number Diff line change
Expand Up @@ -546,17 +546,86 @@ $break-medium: 767px;
display: none;
}

.ais-SearchBox-form .ais-AiModeButton {
.ais-SearchBox-form .ais-AiModeButton-pill {
position: absolute;
right: 0.375rem;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: stretch;
}

.ais-AiModeButton-pill .ais-AiModeButton {
border-bottom-right-radius: 0;
border-right: 0;
border-top-right-radius: 0;

transition: background-color var(--ais-transition-duration)
var(--ais-transition-timing-function),
border-color var(--ais-transition-duration)
var(--ais-transition-timing-function),
color var(--ais-transition-duration)
var(--ais-transition-timing-function),
border-radius var(--ais-transition-duration)
var(--ais-transition-timing-function);
}

.ais-AiModeButton-enterHint {
font-size: calc(var(--ais-font-size) * 0.75);
opacity: 0.7;

transition: opacity var(--ais-transition-duration)
var(--ais-transition-timing-function);
}

.ais-AiModeButton-pill--disabled .ais-AiModeButton,
.ais-AiModeButton-pill--disabled .ais-AiModeButton-dismiss {
background-color: transparent;
}

.ais-AiModeButton-pill--disabled .ais-AiModeButton-enterHint {
display: none;
}

@media (hover: hover) {
.ais-AiModeButton-pill--disabled .ais-AiModeButton:hover,
.ais-AiModeButton-pill--disabled .ais-AiModeButton-dismiss:hover {
background-color: rgba(var(--ais-primary-color-rgb), 0.08);
}
}

.ais-AiModeButton-dismiss {
align-items: center;
background-color: rgba(var(--ais-primary-color-rgb), 0.08);
border: 1px solid rgba(var(--ais-primary-color-rgb), 0.3);
border-bottom-left-radius: 0;
border-top-left-radius: 0;
border-radius: 0 var(--ais-border-radius-sm) var(--ais-border-radius-sm) 0;
color: rgba(var(--ais-primary-color-rgb), 1);
cursor: pointer;
display: inline-flex;
font: inherit;
font-size: 1rem;
justify-content: center;
line-height: 1;
padding: 0 0.5rem;
transition: background-color var(--ais-transition-duration)
var(--ais-transition-timing-function),
border-color var(--ais-transition-duration)
var(--ais-transition-timing-function);

@media (hover: hover) {
&:hover {
background-color: rgba(var(--ais-primary-color-rgb), 0.15);
border-color: rgba(var(--ais-primary-color-rgb), 1);
}
}
}

.ais-SearchBox-form:has(.ais-AiModeButton) .ais-SearchBox-reset,
.ais-SearchBox-form:has(.ais-AiModeButton)
.ais-SearchBox-form:has(.ais-AiModeButton-pill) .ais-SearchBox-reset,
.ais-SearchBox-form:has(.ais-AiModeButton-pill)
.ais-SearchBox-loadingIndicator {
right: 7rem;
right: 8.5rem;
}

.ais-Menu-searchBox,
Expand Down
71 changes: 56 additions & 15 deletions packages/instantsearch.js/src/components/SearchBox/SearchBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ type SearchBoxPropsWithDefaultProps = SearchBoxProps &
type SearchBoxState = {
query: string;
focused: boolean;
aiModeActive: boolean;
};

class SearchBox extends Component<
Expand All @@ -84,6 +85,7 @@ class SearchBox extends Component<
public state = {
query: this.props.query,
focused: false,
aiModeActive: true,
};

private input = createRef<HTMLInputElement>();
Expand Down Expand Up @@ -133,15 +135,20 @@ class SearchBox extends Component<
}

private onSubmit = (event: Event) => {
const { searchAsYouType, refine, onSubmit } = this.props;
const { searchAsYouType, refine, onSubmit, onAiModeClick } = this.props;
const aiModeSubmitsToChat =
Boolean(onAiModeClick) && this.state.aiModeActive;

event.preventDefault();
event.stopPropagation();
if (this.input.current) {
this.input.current.blur();
}

if (!searchAsYouType) {
if (aiModeSubmitsToChat && onAiModeClick) {
// AI mode pill is active: route Enter to the chat instead of the search.
onAiModeClick(this.state.query);
} else if (!searchAsYouType) {
refine(this.state.query);
}

Expand All @@ -150,6 +157,11 @@ class SearchBox extends Component<
return false;
};

private onAiModeToggleShortcut = (event: Event) => {
event.preventDefault();
this.setState((prev) => ({ aiModeActive: !prev.aiModeActive }));
};

private onReset = (event: Event) => {
const { refine, onReset } = this.props;
const query = '';
Expand Down Expand Up @@ -180,6 +192,8 @@ class SearchBox extends Component<

private onAiModeClick = (event: Event) => {
event.preventDefault();
// Pill body always submits to chat. The Enter shortcut is what gets
// disabled via the side toggle.
this.props.onAiModeClick?.(this.state.query);
};

Expand Down Expand Up @@ -277,19 +291,46 @@ class SearchBox extends Component<
)}

{onAiModeClick && (
<Template
templateKey="aiMode"
rootTagName="button"
rootProps={{
className: cssClasses.aiModeButton,
type: 'button',
title: 'AI Mode',
disabled: aiModeButtonDisabled,
onClick: this.onAiModeClick,
}}
templates={templates}
data={{ cssClasses }}
/>
<span
className={
'ais-AiModeButton-pill' +
(this.state.aiModeActive
? ''
: ' ais-AiModeButton-pill--disabled')
}
>
<Template
templateKey="aiMode"
rootTagName="button"
rootProps={{
className: cssClasses.aiModeButton,
type: 'button',
title: 'Ask AI',
disabled: aiModeButtonDisabled,
onClick: this.onAiModeClick,
}}
templates={templates}
data={{ cssClasses }}
/>
<button
type="button"
className="ais-AiModeButton-dismiss"
title={
this.state.aiModeActive
? 'Disable Enter shortcut for AI mode'
: 'Re-enable Enter shortcut for AI mode'
}
aria-label={
this.state.aiModeActive
? 'Disable Enter shortcut for AI mode'
: 'Re-enable Enter shortcut for AI mode'
}
aria-pressed={this.state.aiModeActive}
onClick={this.onAiModeToggleShortcut}
>
{this.state.aiModeActive ? '×' : '⏎'}
</button>
</span>
)}
</form>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1090,9 +1090,10 @@ type AutocompleteWidgetParams<TItem extends BaseHit> = {
translations?: Partial<AutocompleteTranslations>;

/**
* When true, renders an AI mode button inside the search input
* that opens the Chat widget and sends the current query.
* Requires a Chat widget on the same index.
* Whether to render the AI mode button inside the search input, which opens
* the Chat widget and sends the current query. Defaults to `true`; set to
* `false` to opt out. Requires a Chat widget on the same index to do
* anything when clicked.
*/
aiMode?: boolean;
};
Expand Down Expand Up @@ -1123,7 +1124,7 @@ export function EXPERIMENTAL_autocomplete<TItem extends BaseHit = BaseHit>(
autofocus,
detachedMediaQuery,
translations: userTranslations = {},
aiMode,
aiMode = true,
} = widgetParams || {};

if (!container) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ const defaultTemplate: SearchBoxComponentTemplates = {
clip-rule="evenodd"
/>
</svg>
<span className={cssClasses.aiModeLabel}>AI Mode</span>
<span className={cssClasses.aiModeLabel}>Ask AI</span>
<span className="ais-AiModeButton-enterHint" aria-hidden="true">
</span>
</Fragment>
);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,10 @@ export type SearchBoxWidgetParams = {
*/
queryHook?: (query: string, hook: (value: string) => void) => void;
/**
* When true, renders an AI mode button inside the search box
* that opens the Chat widget and sends the current query.
* Requires a Chat widget on the same index.
* Whether to render the AI mode button inside the search box, which opens
* the Chat widget and sends the current query. Defaults to `true`; set to
* `false` to opt out. Requires a Chat widget on the same index to do
* anything when clicked.
*/
aiMode?: boolean;
};
Expand Down Expand Up @@ -265,7 +266,7 @@ const searchBox: SearchBoxWidget = function searchBox(widgetParams) {
showLoadingIndicator = true,
queryHook,
templates: userTemplates = {},
aiMode,
aiMode = true,
} = widgetParams || {};
if (!container) {
throw new Error(withUsage('The `container` option is required.'));
Expand Down
70 changes: 52 additions & 18 deletions packages/react-instantsearch/src/ui/SearchBox.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { cx } from 'instantsearch-ui-components';
import React from 'react';
import React, { useState } from 'react';

export type IconProps = {
classNames: Partial<SearchBoxClassNames>;
Expand Down Expand Up @@ -203,11 +203,17 @@ export function SearchBox({
translations,
...props
}: SearchBoxProps) {
const [aiModeActive, setAiModeActive] = useState(true);
const pillVisible = Boolean(onAiModeClick);

function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
event.stopPropagation();

if (onSubmit) {
if (pillVisible && aiModeActive && onAiModeClick) {
// AI mode pill is active: route Enter to the chat instead of the search.
onAiModeClick();
} else if (onSubmit) {
onSubmit(event);
}

Expand Down Expand Up @@ -277,25 +283,53 @@ export function SearchBox({
>
<ResetIcon classNames={classNames} />
</button>
{onAiModeClick && (
<button
{pillVisible && (
<span
className={cx(
'ais-AiModeButton',
classNames.aiModeButton
'ais-AiModeButton-pill',
!aiModeActive && 'ais-AiModeButton-pill--disabled'
)}
type="button"
title={translations.aiModeButtonTitle || 'AI Mode'}
disabled={aiModeButtonDisabled}
onClick={(e) => {
e.preventDefault();
onAiModeClick();
}}
>
<AiModeIcon classNames={classNames} />
<span className="ais-AiModeButton-label">
{translations.aiModeButtonTitle || 'AI Mode'}
</span>
</button>
<button
className={cx('ais-AiModeButton', classNames.aiModeButton)}
type="button"
title={translations.aiModeButtonTitle || 'Ask AI'}
disabled={aiModeButtonDisabled}
onClick={(e) => {
e.preventDefault();
onAiModeClick?.();
}}
>
<AiModeIcon classNames={classNames} />
<span className="ais-AiModeButton-label">
{translations.aiModeButtonTitle || 'Ask AI'}
</span>
<span className="ais-AiModeButton-enterHint" aria-hidden="true">
</span>
</button>
<button
className="ais-AiModeButton-dismiss"
type="button"
title={
aiModeActive
? 'Disable Enter shortcut for AI mode'
: 'Re-enable Enter shortcut for AI mode'
}
aria-label={
aiModeActive
? 'Disable Enter shortcut for AI mode'
: 'Re-enable Enter shortcut for AI mode'
}
aria-pressed={aiModeActive}
onClick={(e) => {
e.preventDefault();
setAiModeActive((prev) => !prev);
}}
>
{aiModeActive ? '×' : '⏎'}
</button>
</span>
)}
<span
className={cx(
Expand Down
11 changes: 6 additions & 5 deletions packages/react-instantsearch/src/widgets/SearchBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ export type SearchBoxProps = Omit<
*/
ignoreCompositionEvents?: boolean;
/**
* When true, renders an AI mode button inside the search box
* that opens the Chat widget and sends the current query.
* Requires a Chat widget on the same index.
* Whether the AI mode pill is active. When `true` (the default), submitting
* the search box (e.g. pressing Enter) opens the Chat widget and sends the
* current query instead of running a search. Set to `false` to opt out.
* Requires a Chat widget on the same index to do anything when submitted.
*/
aiMode?: boolean;
translations?: Partial<UiProps['translations']>;
Expand All @@ -51,7 +52,7 @@ export function SearchBox({
queryHook,
searchAsYouType = true,
ignoreCompositionEvents = false,
aiMode,
aiMode = true,
translations,
...props
}: SearchBoxProps) {
Expand Down Expand Up @@ -133,7 +134,7 @@ export function SearchBox({
translations: {
submitButtonTitle: 'Submit the search query',
resetButtonTitle: 'Clear the search query',
aiModeButtonTitle: 'AI Mode',
aiModeButtonTitle: 'Ask AI',
...translations,
},
};
Expand Down
Loading