You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

FacetBox.tsx 5.6KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2024 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. import styled from '@emotion/styled';
  21. import classNames from 'classnames';
  22. import { uniqueId } from 'lodash';
  23. import * as React from 'react';
  24. import tw from 'twin.macro';
  25. import { themeColor } from '../helpers';
  26. import { BareButton } from '../sonar-aligned/components/buttons';
  27. import { Badge } from './Badge';
  28. import { DestructiveIcon } from './InteractiveIcon';
  29. import { Spinner } from './Spinner';
  30. import { Tooltip as SCTooltip } from './Tooltip';
  31. import { OpenCloseIndicator } from './icons';
  32. import { CloseIcon } from './icons/CloseIcon';
  33. export interface FacetBoxProps {
  34. ariaLabel?: string;
  35. children: React.ReactNode;
  36. className?: string;
  37. clearIconLabel?: string;
  38. count?: number;
  39. countLabel?: string;
  40. 'data-property'?: string;
  41. disabled?: boolean;
  42. disabledHelper?: string;
  43. hasEmbeddedFacets?: boolean;
  44. help?: React.ReactNode;
  45. id?: string;
  46. inner?: boolean;
  47. loading?: boolean;
  48. name: string;
  49. onClear?: () => void;
  50. onClick?: (isOpen: boolean) => void;
  51. open?: boolean;
  52. tooltipComponent?: React.ComponentType<React.PropsWithChildren<{ overlay: React.ReactNode }>>;
  53. }
  54. export function FacetBox(props: FacetBoxProps) {
  55. const {
  56. ariaLabel,
  57. children,
  58. className,
  59. clearIconLabel,
  60. count,
  61. countLabel,
  62. 'data-property': dataProperty,
  63. disabled = false,
  64. disabledHelper,
  65. hasEmbeddedFacets = false,
  66. help,
  67. id: idProp,
  68. inner = false,
  69. loading = false,
  70. name,
  71. onClear,
  72. onClick,
  73. open = false,
  74. tooltipComponent,
  75. } = props;
  76. const clearable = !disabled && Boolean(onClear) && count !== undefined && count > 0;
  77. const counter = count ?? 0;
  78. const expandable = !disabled && Boolean(onClick);
  79. const id = React.useMemo(() => idProp ?? uniqueId('filter-facet-'), [idProp]);
  80. const Tooltip = tooltipComponent ?? SCTooltip;
  81. return (
  82. <Accordion
  83. className={classNames(className, { open })}
  84. data-property={dataProperty}
  85. hasEmbeddedFacets={hasEmbeddedFacets}
  86. inner={inner}
  87. >
  88. <Header>
  89. <ChevronAndTitle
  90. aria-controls={`${id}-panel`}
  91. aria-disabled={!expandable}
  92. aria-expanded={open}
  93. aria-label={ariaLabel ?? name}
  94. expandable={expandable}
  95. id={`${id}-header`}
  96. onClick={() => {
  97. if (!disabled) {
  98. onClick?.(!open);
  99. }
  100. }}
  101. >
  102. {expandable && <OpenCloseIndicator aria-hidden open={open} />}
  103. {disabled ? (
  104. <Tooltip overlay={disabledHelper}>
  105. <HeaderTitle
  106. aria-disabled
  107. aria-label={`${name}, ${disabledHelper ?? ''}`}
  108. disabled={disabled}
  109. >
  110. {name}
  111. </HeaderTitle>
  112. </Tooltip>
  113. ) : (
  114. <HeaderTitle>{name}</HeaderTitle>
  115. )}
  116. {help && <span className="sw-ml-1">{help}</span>}
  117. </ChevronAndTitle>
  118. {<Spinner loading={loading} />}
  119. {counter > 0 && (
  120. <BadgeAndIcons>
  121. <Badge title={countLabel} variant="counter">
  122. {counter}
  123. </Badge>
  124. {Boolean(clearable) && (
  125. <Tooltip overlay={clearIconLabel}>
  126. <ClearIcon
  127. Icon={CloseIcon}
  128. aria-label={clearIconLabel ?? ''}
  129. data-testid={`clear-${name}`}
  130. onClick={onClear}
  131. size="small"
  132. />
  133. </Tooltip>
  134. )}
  135. </BadgeAndIcons>
  136. )}
  137. </Header>
  138. {open && (
  139. <div aria-labelledby={`${id}-header`} id={`${id}-panel`} role="group">
  140. {children}
  141. </div>
  142. )}
  143. </Accordion>
  144. );
  145. }
  146. FacetBox.displayName = 'FacetBox'; // so that tests don't see the obfuscated production name
  147. const Accordion = styled.div<{
  148. hasEmbeddedFacets?: boolean;
  149. inner?: boolean;
  150. }>`
  151. ${tw`sw-flex-col`};
  152. ${tw`sw-flex`};
  153. ${tw`sw-gap-3`};
  154. ${({ hasEmbeddedFacets }) => (hasEmbeddedFacets ? tw`sw-gap-0` : '')};
  155. ${({ inner }) => (inner ? tw`sw-gap-1 sw-ml-3 sw-mt-1` : '')};
  156. `;
  157. const BadgeAndIcons = styled.div`
  158. ${tw`sw-flex`};
  159. ${tw`sw-gap-2`};
  160. `;
  161. const ChevronAndTitle = styled(BareButton)<{
  162. expandable?: boolean;
  163. }>`
  164. ${tw`sw-flex`};
  165. ${tw`sw-gap-1`};
  166. ${tw`sw-h-9`};
  167. ${tw`sw-items-center`};
  168. cursor: ${({ expandable }) => (expandable ? 'pointer' : 'default')};
  169. `;
  170. const ClearIcon = styled(DestructiveIcon)`
  171. --color: ${themeColor('dangerButton')};
  172. `;
  173. const Header = styled.div`
  174. ${tw`sw-flex`};
  175. ${tw`sw-gap-3`};
  176. ${tw`sw-items-center`};
  177. ${tw`sw-justify-between`};
  178. `;
  179. const HeaderTitle = styled.span<{
  180. disabled?: boolean;
  181. }>`
  182. ${tw`sw-body-sm-highlight`};
  183. color: ${({ disabled }) =>
  184. disabled ? themeColor('facetHeaderDisabled') : themeColor('facetHeader')};
  185. cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'inherit')};
  186. `;