/** @jsx h */ import type { JSX } from 'preact'; import { h, createRef, Component } from 'preact'; import { cx } from '@algolia/ui-components-shared'; import { isSpecialClick, isEqual } from '../../lib/utils'; import type { PreparedTemplateProps } from '../../lib/templating'; import Template from '../Template/Template'; import RefinementListItem from './RefinementListItem'; import type { SearchBoxComponentCSSClasses, SearchBoxComponentTemplates, } from '../SearchBox/SearchBox'; import SearchBox from '../SearchBox/SearchBox'; import type { HierarchicalMenuItem } from '../../connectors/hierarchical-menu/connectHierarchicalMenu'; import type { ComponentCSSClasses, CreateURL, Templates } from '../../types'; import type { RefinementListOwnCSSClasses } from '../../widgets/refinement-list/refinement-list'; import type { RatingMenuComponentCSSClasses } from '../../widgets/rating-menu/rating-menu'; import type { HierarchicalMenuComponentCSSClasses } from '../../widgets/hierarchical-menu/hierarchical-menu'; // CSS types type RefinementListOptionalClasses = | 'noResults' | 'checkbox' | 'labelText' | 'showMore' | 'disabledShowMore' | 'searchBox' | 'count'; type RefinementListWidgetCSSClasses = ComponentCSSClasses; type RefinementListRequiredCSSClasses = Omit< RefinementListWidgetCSSClasses, RefinementListOptionalClasses > & Partial>; export type RefinementListComponentCSSClasses = RefinementListRequiredCSSClasses & { searchable?: SearchBoxComponentCSSClasses; } & Partial> & Partial< Pick >; type FacetValue = { value: string; label: string; highlighted?: string; count?: number; isRefined: boolean; data?: HierarchicalMenuItem[] | null; }; export type RefinementListProps = { createURL: CreateURL; cssClasses: RefinementListComponentCSSClasses; depth?: number; facetValues?: FacetValue[]; attribute?: string; templateProps: PreparedTemplateProps; toggleRefinement: (value: string) => void; showMore?: boolean; toggleShowMore?: () => void; isShowingMore?: boolean; hasExhaustiveItems?: boolean; canToggleShowMore?: boolean; className?: string; children?: JSX.Element; // searchable props are optional, but will definitely be present in a searchable context isFromSearch?: boolean; searchIsAlwaysActive?: boolean; searchFacetValues?: (query: string) => void; searchPlaceholder?: string; searchBoxTemplateProps?: PreparedTemplateProps; }; const defaultProps = { cssClasses: {}, depth: 0, }; type RefinementListPropsWithDefaultProps = RefinementListProps & Readonly; type RefinementListItemTemplateData = FacetValue & { url: string; } & Pick< RefinementListProps, 'attribute' | 'cssClasses' | 'isFromSearch' >; function isHierarchicalMenuItem( facetValue: FacetValue ): facetValue is HierarchicalMenuItem { return (facetValue as HierarchicalMenuItem).data !== undefined; } class RefinementList extends Component< RefinementListPropsWithDefaultProps > { public static defaultProps = defaultProps; private searchBox = createRef(); public constructor(props: RefinementListPropsWithDefaultProps) { super(props); this.handleItemClick = this.handleItemClick.bind(this); } public shouldComponentUpdate( nextProps: RefinementListPropsWithDefaultProps ) { const areFacetValuesDifferent = !isEqual( this.props.facetValues, nextProps.facetValues ); return areFacetValuesDifferent; } private refine(facetValueToRefine: string) { this.props.toggleRefinement(facetValueToRefine); } private _generateFacetItem(facetValue: FacetValue) { let subItems; if ( isHierarchicalMenuItem(facetValue) && Array.isArray(facetValue.data) && facetValue.data.length > 0 ) { const { root, ...cssClasses } = this.props.cssClasses; subItems = ( ); } const url = this.props.createURL(facetValue.value); const templateData: RefinementListItemTemplateData = { ...facetValue, url, attribute: this.props.attribute, cssClasses: this.props.cssClasses, isFromSearch: this.props.isFromSearch, }; let { value: key } = facetValue; if (facetValue.isRefined !== undefined) { key += `/${facetValue.isRefined}`; } if (facetValue.count !== undefined) { key += `/${facetValue.count}`; } const refinementListItemClassName = cx( this.props.cssClasses.item, facetValue.isRefined && this.props.cssClasses.selectedItem, !facetValue.count && this.props.cssClasses.disabledItem, Boolean( isHierarchicalMenuItem(facetValue) && Array.isArray(facetValue.data) && facetValue.data.length > 0 ) && this.props.cssClasses.parentItem! ); return ( ); } // Click events on DOM tree like LABEL > INPUT will result in two click events // instead of one. // No matter the framework, see https://www.google.com/search?q=click+label+twice // // Thus making it hard to distinguish activation from deactivation because both click events // are very close. Debounce is a solution but hacky. // // So the code here checks if the click was done on or in a LABEL. If this LABEL // has a checkbox inside, we ignore the first click event because we will get another one. // // We also check if the click was done inside a link and then e.preventDefault() because we already // handle the url // // Finally, we always stop propagation of the event to avoid multiple levels RefinementLists to fail: click // on child would click on parent also private handleItemClick({ facetValueToRefine, isRefined, originalEvent, }: { facetValueToRefine: string; isRefined: boolean; originalEvent: MouseEvent; }) { if (isSpecialClick(originalEvent)) { // do not alter the default browser behavior // if one special key is down return; } if ( !(originalEvent.target instanceof HTMLElement) || !(originalEvent.target.parentNode instanceof HTMLElement) ) { return; } if ( isRefined && originalEvent.target.parentNode.querySelector( 'input[type="radio"]:checked' ) ) { // Prevent refinement for being reset if the user clicks on an already checked radio button return; } if (originalEvent.target.tagName === 'INPUT') { this.refine(facetValueToRefine); return; } let parent = originalEvent.target; while (parent !== originalEvent.currentTarget) { if ( parent.tagName === 'LABEL' && (parent.querySelector('input[type="checkbox"]') || parent.querySelector('input[type="radio"]')) ) { return; } if (parent.tagName === 'A' && (parent as HTMLAnchorElement).href) { originalEvent.preventDefault(); } parent = parent.parentNode as HTMLElement; } originalEvent.stopPropagation(); this.refine(facetValueToRefine); } public componentWillReceiveProps( nextProps: RefinementListPropsWithDefaultProps ) { if (this.searchBox.current && !nextProps.isFromSearch) { this.searchBox.current.resetInput(); } } private refineFirstValue() { const firstValue = this.props.facetValues && this.props.facetValues[0]; if (firstValue) { const actualValue = firstValue.value; this.props.toggleRefinement(actualValue); } } public render() { const showMoreButtonClassName = cx( this.props.cssClasses.showMore, !(this.props.showMore === true && this.props.canToggleShowMore) && this.props.cssClasses.disabledShowMore ); const showMoreButton = this.props.showMore === true && (