diff --git a/packages/react-instantsearch-core/src/connectors/useRatingMenu.ts b/packages/react-instantsearch-core/src/connectors/useRatingMenu.ts new file mode 100644 index 0000000000..a7c5c548b4 --- /dev/null +++ b/packages/react-instantsearch-core/src/connectors/useRatingMenu.ts @@ -0,0 +1,22 @@ +import connectRatingMenu from 'instantsearch.js/es/connectors/rating-menu/connectRatingMenu'; + +import { useConnector } from '../hooks/useConnector'; + +import type { AdditionalWidgetProperties } from '../hooks/useConnector'; +import type { + RatingMenuConnectorParams, + RatingMenuWidgetDescription, +} from 'instantsearch.js/es/connectors/rating-menu/connectRatingMenu'; + +export type UseRatingMenuProps = RatingMenuConnectorParams; + +export function useRatingMenu( + props: UseRatingMenuProps, + additionalWidgetProperties?: AdditionalWidgetProperties +) { + return useConnector( + connectRatingMenu, + props, + additionalWidgetProperties + ); +} diff --git a/packages/react-instantsearch-core/src/index.ts b/packages/react-instantsearch-core/src/index.ts index bb96081d09..32dba73190 100644 --- a/packages/react-instantsearch-core/src/index.ts +++ b/packages/react-instantsearch-core/src/index.ts @@ -26,6 +26,7 @@ export * from './connectors/usePagination'; export * from './connectors/usePoweredBy'; export * from './connectors/useQueryRules'; export * from './connectors/useRange'; +export * from './connectors/useRatingMenu'; export * from './connectors/useRefinementList'; export * from './connectors/useRelatedProducts'; export * from './connectors/useSearchBox'; diff --git a/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx b/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx index 836217e428..658a53dff7 100644 --- a/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx +++ b/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx @@ -12,6 +12,8 @@ import { HierarchicalMenu, Breadcrumb, Menu, + MenuSelect, + NumericMenu, Pagination, InfiniteHits, SearchBox, @@ -19,6 +21,7 @@ import { Hits, Index, RangeInput, + RatingMenu, HitsPerPage, ClearRefinements, CurrentRefinements, @@ -282,11 +285,21 @@ const testSetups: TestSetupsMap = { ); }, - createRatingMenuWidgetTests() { - throw new Error('RatingMenu is not supported in React InstantSearch'); + createRatingMenuWidgetTests({ instantSearchOptions, widgetParams }) { + render( + + + + + ); }, - createNumericMenuWidgetTests() { - throw new Error('NumericMenu is not supported in React InstantSearch'); + createNumericMenuWidgetTests({ instantSearchOptions, widgetParams }) { + render( + + + + + ); }, createToggleRefinementWidgetTests({ instantSearchOptions, widgetParams }) { render( @@ -382,8 +395,13 @@ const testSetups: TestSetupsMap = { flavor: 'react-instantsearch', }; }, - createMenuSelectWidgetTests() { - throw new Error('MenuSelect is not supported in React InstantSearch'); + createMenuSelectWidgetTests({ instantSearchOptions, widgetParams }) { + render( + + + + + ); }, createDynamicWidgetsWidgetTests({ instantSearchOptions, widgetParams }) { render( @@ -454,12 +472,7 @@ const testOptions: TestOptionsMap = { createInfiniteHitsWidgetTests: { act }, createHitsWidgetTests: { act }, createRangeInputWidgetTests: { act }, - createRatingMenuWidgetTests: { - act, - skippedTests: { - 'RatingMenu widget common tests': true, - }, - }, + createRatingMenuWidgetTests: { act }, createInstantSearchWidgetTests: { act }, createHitsPerPageWidgetTests: { act }, createClearRefinementsWidgetTests: { act }, @@ -468,24 +481,14 @@ const testOptions: TestOptionsMap = { createSearchBoxWidgetTests: { act }, createSortByWidgetTests: { act }, createStatsWidgetTests: { act }, - createNumericMenuWidgetTests: { - act, - skippedTests: { - 'NumericMenu widget common tests': true, - }, - }, + createNumericMenuWidgetTests: { act }, createRelatedProductsWidgetTests: { act }, createFrequentlyBoughtTogetherWidgetTests: { act }, createTrendingItemsWidgetTests: { act }, createTrendingFacetsWidgetTests: { act }, createLookingSimilarWidgetTests: { act }, createPoweredByWidgetTests: { act }, - createMenuSelectWidgetTests: { - act, - skippedTests: { - 'MenuSelect widget common tests': true, - }, - }, + createMenuSelectWidgetTests: { act }, createDynamicWidgetsWidgetTests: { act }, createChatWidgetTests: { act, diff --git a/packages/react-instantsearch/src/ui/MenuSelect.tsx b/packages/react-instantsearch/src/ui/MenuSelect.tsx new file mode 100644 index 0000000000..a218fb9d54 --- /dev/null +++ b/packages/react-instantsearch/src/ui/MenuSelect.tsx @@ -0,0 +1,80 @@ +import { cx } from 'instantsearch-ui-components'; +import React from 'react'; + +import type { MenuItem } from 'instantsearch.js/es/connectors/menu/connectMenu'; + +export type MenuSelectProps = Omit< + React.ComponentProps<'div'>, + 'onChange' | 'defaultValue' +> & { + items: MenuItem[]; + value: string; + onChange?: (value: string) => void; + classNames?: Partial; + defaultOptionLabel?: string; + itemLabel?: (item: MenuItem) => React.ReactNode; +}; + +export type MenuSelectClassNames = { + /** + * Class names to apply to the root element + */ + root: string; + /** + * Class names to apply to the root element when there are no refinements possible + */ + noRefinementRoot: string; + /** + * Class names to apply to the select element + */ + select: string; + /** + * Class names to apply to the option element + */ + option: string; +}; + +export function MenuSelect({ + items, + value, + onChange = () => {}, + classNames = {}, + defaultOptionLabel = 'See all', + itemLabel = (item) => `${item.label} (${item.count})`, + ...props +}: MenuSelectProps) { + return ( +
+ +
+ ); +} diff --git a/packages/react-instantsearch/src/ui/NumericMenu.tsx b/packages/react-instantsearch/src/ui/NumericMenu.tsx new file mode 100644 index 0000000000..7fbf0e5c9b --- /dev/null +++ b/packages/react-instantsearch/src/ui/NumericMenu.tsx @@ -0,0 +1,100 @@ +import { cx } from 'instantsearch-ui-components'; +import React from 'react'; + +import type { NumericMenuRenderStateItem } from 'instantsearch.js/es/connectors/numeric-menu/connectNumericMenu'; + +export type NumericMenuProps = Omit, 'onChange'> & { + items: NumericMenuRenderStateItem[]; + attribute: string; + onRefine: (value: string) => void; + classNames?: Partial; +}; + +export type NumericMenuClassNames = { + /** + * Class names to apply to the root element + */ + root: string; + /** + * Class names to apply to the root element when there are no refinements possible + */ + noRefinementRoot: string; + /** + * Class names to apply to the list element + */ + list: string; + /** + * Class names to apply to each item element + */ + item: string; + /** + * Class names to apply to each selected item element + */ + selectedItem: string; + /** + * Class names to apply to each label element + */ + label: string; + /** + * Class names to apply to each radio input element + */ + radio: string; + /** + * Class names to apply to each label text element + */ + labelText: string; +}; + +export function NumericMenu({ + items, + attribute, + onRefine, + classNames = {}, + ...props +}: NumericMenuProps) { + return ( +
+
    + {items.map((item) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/packages/react-instantsearch/src/ui/RatingMenu.tsx b/packages/react-instantsearch/src/ui/RatingMenu.tsx new file mode 100644 index 0000000000..9f8ca17e47 --- /dev/null +++ b/packages/react-instantsearch/src/ui/RatingMenu.tsx @@ -0,0 +1,180 @@ +import { cx } from 'instantsearch-ui-components'; +import React from 'react'; + +import { isModifierClick } from './lib/isModifierClick'; + +import type { RatingMenuRenderState } from 'instantsearch.js/es/connectors/rating-menu/connectRatingMenu'; + +export type RatingMenuProps = React.ComponentProps<'div'> & { + items: RatingMenuRenderState['items']; + createURL: RatingMenuRenderState['createURL']; + onRefine: (value: string) => void; + classNames?: Partial; + translations?: Partial; +}; + +export type RatingMenuClassNames = { + /** + * Class names to apply to the root element + */ + root: string; + /** + * Class names to apply to the root element when there are no refinements possible + */ + noRefinementRoot: string; + /** + * Class names to apply to the list element + */ + list: string; + /** + * Class names to apply to each item element + */ + item: string; + /** + * Class names to apply to each selected item element + */ + selectedItem: string; + /** + * Class names to apply to each disabled item element + */ + disabledItem: string; + /** + * Class names to apply to each link element + */ + link: string; + /** + * Class names to apply to each star icon element + */ + starIcon: string; + /** + * Class names to apply to each full star icon element + */ + fullStarIcon: string; + /** + * Class names to apply to each empty star icon element + */ + emptyStarIcon: string; + /** + * Class names to apply to each label element + */ + label: string; + /** + * Class names to apply to each count element + */ + count: string; +}; + +export type RatingMenuTranslations = { + /** + * The text that follows the rating in each link's accessible label. + * Receives the rating value as input. + */ + ariaUp: (value: string) => string; + /** + * The text that follows the rating stars. + */ + andUp: () => React.ReactNode; +}; + +export function RatingMenu({ + items, + createURL, + onRefine, + classNames = {}, + translations, + ...props +}: RatingMenuProps) { + const ariaUp = translations?.ariaUp ?? ((value) => `${value} & up`); + const andUp = translations?.andUp ?? (() => <>& Up); + + return ( + + ); +} diff --git a/packages/react-instantsearch/src/widgets/MenuSelect.tsx b/packages/react-instantsearch/src/widgets/MenuSelect.tsx new file mode 100644 index 0000000000..8f57bc068d --- /dev/null +++ b/packages/react-instantsearch/src/widgets/MenuSelect.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { useMenu } from 'react-instantsearch-core'; + +import { MenuSelect as MenuSelectUiComponent } from '../ui/MenuSelect'; + +import type { MenuSelectProps as MenuSelectUiComponentProps } from '../ui/MenuSelect'; +import type { UseMenuProps } from 'react-instantsearch-core'; + +type UiProps = Pick; + +export type MenuSelectProps = Omit & + Omit; + +export function MenuSelect({ + attribute, + limit, + sortBy, + transformItems, + ...props +}: MenuSelectProps) { + const { items, refine } = useMenu( + { + attribute, + limit, + sortBy, + transformItems, + }, + { + $$widgetType: 'ais.menuSelect', + } + ); + + const selected = items.find((item) => item.isRefined); + + const uiProps: UiProps = { + items, + value: selected ? selected.value : '', + onChange: refine, + }; + + return ; +} diff --git a/packages/react-instantsearch/src/widgets/NumericMenu.tsx b/packages/react-instantsearch/src/widgets/NumericMenu.tsx new file mode 100644 index 0000000000..30ce409be5 --- /dev/null +++ b/packages/react-instantsearch/src/widgets/NumericMenu.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useNumericMenu } from 'react-instantsearch-core'; + +import { NumericMenu as NumericMenuUiComponent } from '../ui/NumericMenu'; + +import type { NumericMenuProps as NumericMenuUiComponentProps } from '../ui/NumericMenu'; +import type { UseNumericMenuProps } from 'react-instantsearch-core'; + +type UiProps = Pick< + NumericMenuUiComponentProps, + 'items' | 'attribute' | 'onRefine' +>; + +export type NumericMenuProps = Omit & + UseNumericMenuProps; + +export function NumericMenu({ + attribute, + items, + transformItems, + ...props +}: NumericMenuProps) { + const { items: refinementItems, refine } = useNumericMenu( + { + attribute, + items, + transformItems, + }, + { + $$widgetType: 'ais.numericMenu', + } + ); + + const uiProps: UiProps = { + items: refinementItems, + attribute, + onRefine: refine, + }; + + return ; +} diff --git a/packages/react-instantsearch/src/widgets/RatingMenu.tsx b/packages/react-instantsearch/src/widgets/RatingMenu.tsx new file mode 100644 index 0000000000..c12be28f7c --- /dev/null +++ b/packages/react-instantsearch/src/widgets/RatingMenu.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { useRatingMenu } from 'react-instantsearch-core'; + +import { RatingMenu as RatingMenuUiComponent } from '../ui/RatingMenu'; + +import type { RatingMenuProps as RatingMenuUiComponentProps } from '../ui/RatingMenu'; +import type { UseRatingMenuProps } from 'react-instantsearch-core'; + +type UiProps = Pick< + RatingMenuUiComponentProps, + 'items' | 'createURL' | 'onRefine' +>; + +export type RatingMenuProps = Omit & + UseRatingMenuProps; + +export function RatingMenu({ attribute, max, ...props }: RatingMenuProps) { + const { items, refine, createURL } = useRatingMenu( + { + attribute, + max, + }, + { + $$widgetType: 'ais.ratingMenu', + } + ); + + const uiProps: UiProps = { + items, + createURL, + onRefine: refine, + }; + + return ; +} diff --git a/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx b/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx index bfca035da2..2d00fd6015 100644 --- a/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx +++ b/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx @@ -113,9 +113,18 @@ function Widget({ case 'ToggleRefinement': case 'RangeInput': case 'RefinementList': - case 'Menu': { + case 'Menu': + case 'MenuSelect': { return ; } + case 'NumericMenu': { + return ( + + ); + } + case 'RatingMenu': { + return ; + } case 'SearchBox': { return ; } diff --git a/packages/react-instantsearch/src/widgets/__tests__/all-widgets.test.tsx b/packages/react-instantsearch/src/widgets/__tests__/all-widgets.test.tsx index a4d05be3bb..8f91c252e6 100644 --- a/packages/react-instantsearch/src/widgets/__tests__/all-widgets.test.tsx +++ b/packages/react-instantsearch/src/widgets/__tests__/all-widgets.test.tsx @@ -118,6 +118,16 @@ describe('widgets', () => { "$$widgetType": "ais.menu", "name": "Menu", }, + { + "$$type": "ais.menu", + "$$widgetType": "ais.menuSelect", + "name": "MenuSelect", + }, + { + "$$type": "ais.numericMenu", + "$$widgetType": "ais.numericMenu", + "name": "NumericMenu", + }, { "$$type": "ais.pagination", "$$widgetType": "ais.pagination", @@ -128,6 +138,11 @@ describe('widgets', () => { "$$widgetType": "ais.rangeInput", "name": "RangeInput", }, + { + "$$type": "ais.ratingMenu", + "$$widgetType": "ais.ratingMenu", + "name": "RatingMenu", + }, { "$$type": "ais.refinementList", "$$widgetType": "ais.refinementList", diff --git a/packages/react-instantsearch/src/widgets/index.ts b/packages/react-instantsearch/src/widgets/index.ts index a3c8bb36f8..aaba9befe3 100644 --- a/packages/react-instantsearch/src/widgets/index.ts +++ b/packages/react-instantsearch/src/widgets/index.ts @@ -11,9 +11,12 @@ export * from './HitsPerPage'; export * from './InfiniteHits'; export * from './LookingSimilar'; export * from './Menu'; +export * from './MenuSelect'; +export * from './NumericMenu'; export * from './Pagination'; export * from './PoweredBy'; export * from './RangeInput'; +export * from './RatingMenu'; export * from './RefinementList'; export * from './RelatedProducts'; export * from './ReverseHighlight';