Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ export type AutocompleteClassNames = {
*/
detachedSearchButtonClear: string | string[];
/**
* Class names to apply to the detached cancel button
* Class names to apply to the detached cancel button.
* Kept for backwards compatibility.
*/
detachedCancelButton: string | string[];
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/** @jsx createElement */

import { cx } from '../../lib/cx';
import { createButtonComponent } from '../Button';

import type { ComponentChildren, Renderer } from '../../types';
import type { AutocompleteClassNames } from './Autocomplete';
Expand All @@ -17,12 +16,10 @@ export type AutocompleteDetachedFormContainerProps = {
export function createAutocompleteDetachedFormContainerComponent({
createElement,
}: Renderer) {
const Button = createButtonComponent({ createElement });

return function AutocompleteDetachedFormContainer(
userProps: AutocompleteDetachedFormContainerProps
) {
const { children, classNames = {}, onCancel, translations } = userProps;
const { children, classNames = {} } = userProps;
Comment on lines 20 to +22
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AutocompleteDetachedFormContainer no longer renders/uses onCancel and translations, but those props are still required by AutocompleteDetachedFormContainerProps. This forces new consumers to pass unused values and makes the public API misleading. Consider making these props optional (and/or marking them deprecated in the type) now that the cancel button isn’t rendered.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it not still using it for the title of the back button? @copilot do you mind explaining?


return (
<div
Expand All @@ -32,16 +29,6 @@ export function createAutocompleteDetachedFormContainerComponent({
)}
>
{children}
<Button
variant="ghost"
className={cx(
'ais-AutocompleteDetachedCancelButton',
classNames.detachedCancelButton
)}
onClick={onCancel}
>
{translations.detachedCancelButtonText}
</Button>
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @jsx createElement */
import { ClearIcon, LoadingIcon, SubmitIcon } from './icons';
import { BackIcon, ClearIcon, LoadingIcon, SubmitIcon } from './icons';

import type { ComponentProps, Renderer } from '../..';

Expand All @@ -8,11 +8,25 @@ export type AutocompleteSearchProps = {
onClear: () => void;
query: string;
isSearchStalled: boolean;
onSubmit?: () => void;
isDetached?: boolean;
submitTitle?: string;
};

export function createAutocompleteSearchComponent({ createElement }: Renderer) {
return function AutocompleteSearch(userProps: AutocompleteSearchProps) {
const { inputProps, onClear, query, isSearchStalled } = userProps;
const {
inputProps,
onClear,
query,
isSearchStalled,
onSubmit,
isDetached,
submitTitle,
} = userProps;

const isBackButton = Boolean(isDetached && onSubmit);
const resolvedCancelTitle = submitTitle ?? 'Close';
const inputRef = inputProps.ref as { current: HTMLInputElement | null };

return (
Expand All @@ -21,15 +35,30 @@ export function createAutocompleteSearchComponent({ createElement }: Renderer) {
action=""
noValidate
role="search"
onSubmit={(e) => e.preventDefault()}
onSubmit={(e) => {
e.preventDefault();
}}
onReset={() => inputRef.current?.focus()}
>
<div className="ais-AutocompleteInputWrapperPrefix">
{isBackButton && (
<button
className="ais-AutocompleteBackButton"
type="button"
title={resolvedCancelTitle}
onClick={onSubmit}
hidden={isSearchStalled}
>
<BackIcon createElement={createElement} />
</button>
)}
{/* Always render the label so aria-labelledby on the input keeps working */}
<label
className="ais-AutocompleteLabel"
aria-label="Submit"
htmlFor={inputProps.id}
id={`${inputProps.id}-label`}
hidden={isBackButton || undefined}
>
<button
className="ais-AutocompleteSubmitButton"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts
*/
/** @jsx createElement */
import { render } from '@testing-library/preact';
import userEvent from '@testing-library/user-event';
import { createElement, createRef, Fragment } from 'preact';

import { createAutocompleteDetachedFormContainerComponent } from '../AutocompleteDetachedFormContainer';
import { createAutocompleteSearchComponent } from '../AutocompleteSearch';

const AutocompleteSearch = createAutocompleteSearchComponent({
createElement,
Fragment,
});

const AutocompleteDetachedFormContainer =
createAutocompleteDetachedFormContainerComponent({
createElement,
Fragment,
});

describe('AutocompleteSearch', () => {
test('renders a back button and closes on click in detached mode', () => {
const onSubmit = jest.fn();
const inputRef = createRef<HTMLInputElement>();
const { container, getByTitle } = render(
<AutocompleteSearch
inputProps={{
id: 'detached-search',
ref: inputRef,
onInput: jest.fn(),
}}
onClear={jest.fn()}
query="iphone"
isSearchStalled={false}
onSubmit={onSubmit}
isDetached={true}
submitTitle="Close"
/>
);

userEvent.click(getByTitle('Close'));

expect(onSubmit).toHaveBeenCalledTimes(1);
// A dedicated back button is rendered – not the submit button
expect(
container.querySelector('.ais-AutocompleteBackButton')
).not.toBeNull();
expect(container.querySelector('.ais-AutocompleteBackIcon')).not.toBeNull();
// Submit button is hidden (label hidden), back button is separate
expect(
container.querySelector<HTMLLabelElement>('.ais-AutocompleteLabel')
?.hidden
).toBe(true);
expect(
container.querySelector('.ais-AutocompleteSubmitButton')
).not.toBeNull();
});

test('renders the submit button in non-detached mode', () => {
const inputRef = createRef<HTMLInputElement>();
const { container, getByTitle } = render(
<AutocompleteSearch
inputProps={{
id: 'default-search',
ref: inputRef,
onInput: jest.fn(),
}}
onClear={jest.fn()}
query=""
isSearchStalled={false}
/>
);

expect(getByTitle('Submit').getAttribute('type')).toBe('submit');
expect(
container.querySelector('.ais-AutocompleteSubmitIcon')
).not.toBeNull();
expect(container.querySelector('.ais-AutocompleteBackButton')).toBeNull();
expect(container.querySelector('.ais-AutocompleteBackIcon')).toBeNull();
});
});

describe('AutocompleteDetachedFormContainer', () => {
test('keeps backwards-compatible props without rendering a cancel button', () => {
const { container, queryByText } = render(
<AutocompleteDetachedFormContainer
onCancel={jest.fn()}
translations={{
detachedCancelButtonText: 'Cancel',
detachedSearchButtonTitle: 'Search',
detachedClearButtonTitle: 'Clear',
}}
>
<div>Search form</div>
</AutocompleteDetachedFormContainer>
);

expect(queryByText('Search form')).not.toBeNull();
expect(queryByText('Cancel')).toBeNull();
expect(
container.querySelector('.ais-AutocompleteDetachedCancelButton')
).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,15 @@ export function SearchIcon({ createElement }: IconProps) {
</svg>
);
}

export function BackIcon({ createElement }: IconProps) {
return (
<svg
className="ais-AutocompleteBackIcon"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M9.828 11H21a1 1 0 110 2H9.828l3.586 3.586a1 1 0 01-1.414 1.414l-5.3-5.3a1 1 0 010-1.414l5.3-5.3a1 1 0 111.414 1.414L9.828 11z" />
</svg>
);
}
20 changes: 12 additions & 8 deletions packages/instantsearch.css/src/components/autocomplete.scss
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
}
}
@at-root .ais-AutocompleteSubmitButton,
.ais-AutocompleteBackButton,
.ais-AutocompleteLoadingIndicator {
height: 100%;
padding-left: calc(var(--ais-spacing) * 0.75 - 1px);
Expand All @@ -84,11 +85,20 @@
width: calc(var(--ais-icon-size) + (var(--ais-spacing) * 1.25) - 1px);
}
}
@at-root .ais-AutocompleteSubmitButton {
@at-root .ais-AutocompleteSubmitButton,
.ais-AutocompleteBackButton {
appearance: none;
background: none;
border: 0;
color: rgba(var(--ais-primary-color-rgb), 1);
cursor: pointer;
margin: 0;
svg {
height: auto;
max-height: var(--ais-icon-size);
stroke-width: var(--ais-icon-stroke-width);
width: var(--ais-icon-size);
}
}
@at-root .ais-AutocompleteLoadingIndicator {
align-items: center;
Expand Down Expand Up @@ -517,7 +527,7 @@ body.ais-Autocomplete--detached {
}
}

// Form container inside detached mode (with cancel button)
// Form container inside detached mode
.ais-AutocompleteDetachedFormContainer {
@extend %init;
border-bottom: solid 1px rgba(var(--ais-border-color-rgb), 0.3);
Expand All @@ -532,12 +542,6 @@ body.ais-Autocomplete--detached {
}
}

// Cancel button in detached mode (extends .ais-Button--ghost)
.ais-AutocompleteDetachedCancelButton {
margin: 0 0 0 calc(var(--ais-spacing) / 2);
padding: 0 calc(var(--ais-spacing) / 2);
}

// Search button (shown when detached mode is active but modal is closed)
.ais-AutocompleteDetachedSearchButton {
align-items: center;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,11 @@ function AutocompleteWrapper<TItem extends BaseHit>({
? rawInputProps
: {};

const handleCancel = () => {
setIsModalOpen(false);
setIsOpen(false);
};

const searchBoxContent = (
<AutocompleteSearchBox
query={localQuery}
Expand All @@ -822,6 +827,15 @@ function AutocompleteWrapper<TItem extends BaseHit>({
onRefine('');
}}
isSearchStalled={instantSearchInstance.status === 'stalled'}
onSubmit={() => {
if (isDetached) {
handleCancel();
}
}}
isDetached={isDetached}
submitTitle={
isDetached ? translations.detachedCancelButtonText : undefined
}
/>
);

Expand Down Expand Up @@ -869,20 +883,14 @@ function AutocompleteWrapper<TItem extends BaseHit>({
{isModalOpen && (
<AutocompleteDetachedOverlay
classNames={cssClasses}
onClose={() => {
setIsModalOpen(false);
setIsOpen(false);
}}
onClose={handleCancel}
>
<AutocompleteDetachedContainer
classNames={detachedContainerCssClasses}
>
<AutocompleteDetachedFormContainer
classNames={cssClasses}
onCancel={() => {
setIsModalOpen(false);
setIsOpen(false);
}}
onCancel={handleCancel}
translations={translations}
>
{searchBoxContent}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export type AutocompleteSearchProps = {
query: string;
refine: (query: string) => void;
isSearchStalled: boolean;
onSubmit?: () => void;
isDetached?: boolean;
submitTitle?: string;
};

export function AutocompleteSearch({
Expand All @@ -24,6 +27,9 @@ export function AutocompleteSearch({
query,
refine,
isSearchStalled,
onSubmit,
isDetached,
submitTitle,
}: AutocompleteSearchProps) {
return (
<AutocompleteSearchComponent
Expand All @@ -38,6 +44,9 @@ export function AutocompleteSearch({
onClear={clearQuery}
query={query}
isSearchStalled={isSearchStalled}
onSubmit={onSubmit}
isDetached={isDetached}
submitTitle={submitTitle}
/>
);
}
Loading
Loading