From 60ee0729395f7045f050f077a6ad0f75853971b2 Mon Sep 17 00:00:00 2001 From: Aymeric Giraudet Date: Tue, 12 May 2026 14:09:05 +0200 Subject: [PATCH] feat(react): port MenuSelect, NumericMenu, and RatingMenu Closes the three remaining gaps in `react-instantsearch` that the `port-widget` skill audit surfaced: - **MenuSelect**: a variant of `Menu` that reuses `useMenu`, renders a plain ` onChange(event.target.value)} + > + + {items.map((item) => ( + + ))} + + + ); +} 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';