Skip to content
4 changes: 2 additions & 2 deletions bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
},
{
"path": "./packages/instantsearch.js/dist/instantsearch.production.min.js",
"maxSize": "85 kB"
"maxSize": "87.75 kB"
},
{
"path": "./packages/instantsearch.js/dist/instantsearch.development.js",
"maxSize": "184.50 kB"
"maxSize": "189 kB"
},
{
"path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { cx } from '../../lib/cx';

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

export type AutocompleteProps = ComponentProps<'div'> & {
export type AutocompleteProps = Omit<ComponentProps<'div'>, 'children'> & {
children?: ComponentChildren;
classNames?: Partial<AutocompleteClassNames>;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { cx } from '../../lib/cx';

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

export type AutocompletePanelProps = ComponentProps<'div'> & {
export type AutocompletePanelProps = Omit<ComponentProps<'div'>, 'children'> & {
children?: ComponentChildren;
classNames?: Partial<AutocompletePanelClassNames>;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import type { ComponentProps } from '../../types';

type BaseHit = Record<string, unknown>;

export type AutocompleteIndexConfig<TItem extends BaseHit> = {
indexName: string;
getQuery?: (item: TItem) => string;
getURL?: (item: TItem) => string;
onSelect?: (params: {
item: TItem;
getQuery: () => string;
getURL: () => string;
setQuery: (query: string) => void;
}) => void;
};

type GetInputProps = () => Partial<ComponentProps<'input'>>;

type GetItemProps = (
item: { __indexName: string } & Record<string, unknown>,
index: number
) => Pick<ComponentProps<'li'>, 'id' | 'role' | 'aria-selected'> & {
onSelect: () => void;
};

type GetPanelProps = () => Pick<
ComponentProps<'div'>,
'id' | 'hidden' | 'role' | 'aria-labelledby'
>;

type GetRootProps = () => Pick<ComponentProps<'div'>, 'ref'>;

type CreateAutocompletePropGettersParams = {
useEffect: (effect: () => void, inputs?: readonly unknown[]) => void;
useId: () => string;
useMemo: <TType>(factory: () => TType, inputs: readonly unknown[]) => TType;
useRef: <TType>(initialValue: TType | null) => { current: TType | null };
useState: <TType>(
initialState: TType
) => [TType, (newState: TType) => unknown];
};
Comment on lines +33 to +41
Copy link
Contributor

Choose a reason for hiding this comment

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

I think in a future PR, we could move these into a shared file. I believe there are some parts of the chat widgets that could use this approach.


type UsePropGetters<TItem extends BaseHit> = (params: {
indices: Array<{
indexName: string;
indexId: string;
hits: Array<{ [key: string]: unknown }>;
}>;
indicesConfig: Array<AutocompleteIndexConfig<TItem>>;
onRefine: (query: string) => void;
}) => {
getInputProps: GetInputProps;
getItemProps: GetItemProps;
getPanelProps: GetPanelProps;
getRootProps: GetRootProps;
};

export function createAutocompletePropGetters({
useEffect,
useId,
useMemo,
useRef,
useState,
}: CreateAutocompletePropGettersParams) {
return function usePropGetters<TItem extends BaseHit>({
indices,
indicesConfig,
onRefine,
}: Parameters<UsePropGetters<TItem>>[0]): ReturnType<UsePropGetters<TItem>> {
const getElementId = createGetElementId(useId());
const rootRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [activeDescendant, setActiveDescendant] = useState<
string | undefined
>(undefined);

const { items, itemsIds } = useMemo(
() => buildItems({ indices, indicesConfig, getElementId }),
[indices, indicesConfig, getElementId]
);

useEffect(() => {
const onBodyClick = (event: MouseEvent) => {
if (unwrapRef(rootRef)?.contains(event.target as HTMLElement)) {
return;
}

setIsOpen(false);
};

document.body.addEventListener('click', onBodyClick);

return () => {
document.body.removeEventListener('click', onBodyClick);
};
}, [rootRef]);

const getNextActiveDescendent = (key: string): string | undefined => {
switch (key) {
case 'ArrowLeft':
case 'ArrowUp': {
const prevIndex = itemsIds.indexOf(activeDescendant || '') - 1;
return itemsIds[prevIndex] || itemsIds[itemsIds.length - 1];
}
case 'ArrowRight':
case 'ArrowDown': {
const nextIndex = itemsIds.indexOf(activeDescendant || '') + 1;
return itemsIds[nextIndex] || itemsIds[0];
}
default:
return undefined;
}
};

const submit = (actualActiveDescendant = activeDescendant) => {
setIsOpen(false);
if (actualActiveDescendant && items.has(actualActiveDescendant)) {
const {
item,
config: { onSelect, getQuery, getURL },
} = items.get(actualActiveDescendant)!;
onSelect?.({
item,
getQuery: () => getQuery?.(item) ?? '',
getURL: () => getURL?.(item) ?? '',
setQuery: (query) => onRefine(query),
});
setActiveDescendant(undefined);
}
};

return {
getInputProps: () => ({
id: getElementId('input'),
role: 'combobox',
'aria-autocomplete': 'list',
'aria-expanded': isOpen,
'aria-haspopup': 'grid',
'aria-controls': getElementId('panel'),
'aria-activedescendant': activeDescendant,
onFocus: () => setIsOpen(true),
onKeyDown: (event) => {
if (event.key === 'Escape') {
setActiveDescendant(undefined);
setIsOpen(false);
return;
}
switch (event.key) {
case 'ArrowLeft':
case 'ArrowUp':
case 'ArrowRight':
case 'ArrowDown':
setActiveDescendant(getNextActiveDescendent(event.key));
event.preventDefault();
break;
case 'Enter': {
submit();
break;
}
case 'Tab':
setIsOpen(false);
break;
default:
return;
}
},
onKeyUp: (event) => {
switch (event.key) {
case 'ArrowLeft':
case 'ArrowUp':
case 'ArrowRight':
case 'ArrowDown':
case 'Escape':
case 'Return':
event.preventDefault();
return;
default:
setActiveDescendant(undefined);
break;
}
},
}),
getItemProps: (item, index) => {
const id = getElementId('item', item.__indexName, index);

return {
id,
role: 'row',
'aria-selected': id === activeDescendant,
onSelect: () => submit(id),
};
},
getPanelProps: () => ({
hidden: !isOpen,
id: getElementId('panel'),
role: 'grid',
'aria-labelledby': getElementId('input'),
}),
getRootProps: () => ({
ref: rootRef,
}),
};
};
}

function buildItems<TItem extends BaseHit>({
indices,
indicesConfig,
getElementId,
}: Pick<Parameters<UsePropGetters<TItem>>[0], 'indices' | 'indicesConfig'> & {
getElementId: ReturnType<typeof createGetElementId>;
}) {
const itemsIds = [];
const items = new Map<
string,
{ item: TItem; config: AutocompleteIndexConfig<TItem> }
>();

for (let i = 0; i < indicesConfig.length; i++) {
const config = indicesConfig[i];
const hits = indices[i]?.hits || [];

for (let position = 0; position < hits.length; position++) {
const itemId = getElementId('item', config.indexName, position);
items.set(itemId, {
item: hits[position] as TItem,
config,
});
itemsIds.push(itemId);
}
}
return { items, itemsIds };
}

function createGetElementId(autocompleteId: string) {
return function getElementId(...suffixes: Array<string | number>) {
const prefix = 'autocomplete';
return `${prefix}${autocompleteId}${suffixes.join(':')}`;
};
}

/**
* Returns the framework-agnostic value of a ref.
*/
function unwrapRef<TType>(ref: { current: TType | null }): TType | null {
return ref.current && typeof ref.current === 'object' && 'base' in ref.current
? (ref.current.base as TType) // Preact
: ref.current; // React
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './Autocomplete';
export * from './AutocompleteIndex';
export * from './AutocompletePanel';
export * from './AutocompleteSuggestion';
export * from './createAutocompletePropGetters';
18 changes: 16 additions & 2 deletions packages/instantsearch.js/src/components/SearchBox/SearchBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
SearchBoxCSSClasses,
SearchBoxTemplates,
} from '../../widgets/search-box/search-box';
import type { ComponentProps } from 'preact';

export type SearchBoxComponentCSSClasses =
ComponentCSSClasses<SearchBoxCSSClasses>;
Expand All @@ -34,6 +35,7 @@ type SearchBoxProps = {
onChange?: (event: Event) => void;
onSubmit?: (event: Event) => void;
onReset?: (event: Event) => void;
inputProps?: Partial<ComponentProps<'input'>>;
};

const defaultProps = {
Expand All @@ -51,6 +53,7 @@ const defaultProps = {
onSubmit: noop,
onReset: noop,
refine: noop,
inputProps: {},
};

type SearchBoxPropsWithDefaultProps = SearchBoxProps &
Expand Down Expand Up @@ -86,6 +89,9 @@ class SearchBox extends Component<
}

private onInput = (event: Event) => {
// @ts-expect-error the context incompatibility of `this` doesn't matter
this.props.inputProps.onInput?.(event);

const { searchAsYouType, refine, onChange } = this.props;
const query = (event.target as HTMLInputElement).value;

Expand Down Expand Up @@ -147,11 +153,17 @@ class SearchBox extends Component<
onReset(event);
};

private onBlur = () => {
private onBlur = (event: FocusEvent) => {
// @ts-expect-error the context incompatibility of `this` doesn't matter
this.props.inputProps.onBlur?.(event);

this.setState({ focused: false });
};

private onFocus = () => {
private onFocus: (event: FocusEvent) => void = (event) => {
// @ts-expect-error the context incompatibility of `this` doesn't matter
this.props.inputProps.onFocus?.(event);

this.setState({ focused: true });
};

Expand All @@ -166,6 +178,7 @@ class SearchBox extends Component<
templates,
isSearchStalled,
ariaLabel,
inputProps,
} = this.props;

return (
Expand All @@ -179,6 +192,7 @@ class SearchBox extends Component<
onReset={this.onReset}
>
<input
{...inputProps}
ref={this.input}
value={this.state.query}
disabled={this.props.disabled}
Expand Down
4 changes: 3 additions & 1 deletion packages/instantsearch.js/src/lib/InstantSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,9 @@ See documentation: ${createDocumentationLink({
* Widgets can be added either before or after InstantSearch has started.
* @param widgets The array of widgets to add to InstantSearch.
*/
public addWidgets(widgets: Array<Widget | IndexWidget | Widget[]>) {
public addWidgets(
widgets: Array<Widget | IndexWidget | Array<IndexWidget | Widget>>
) {
if (!Array.isArray(widgets)) {
throw new Error(
withUsage(
Expand Down
11 changes: 10 additions & 1 deletion packages/instantsearch.js/src/types/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,14 @@ type RecommendWidget<
>;
};

type Parent = {
/**
* This gets dynamically added by the `index` widget.
* If the widget has gone through `addWidget`, it will have a parent.
*/
parent?: IndexWidget;
};

type RequiredWidgetLifeCycle<TWidgetDescription extends WidgetDescription> = {
/**
* Identifier for connectors and widgets.
Expand Down Expand Up @@ -331,7 +339,8 @@ export type Widget<
$$type: string;
}
> = Expand<
RequiredWidgetLifeCycle<TWidgetDescription> &
Parent &
RequiredWidgetLifeCycle<TWidgetDescription> &
WidgetType<TWidgetDescription> &
UiStateLifeCycle<TWidgetDescription> &
RenderStateLifeCycle<TWidgetDescription>
Expand Down
Loading