--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+import { uniqueId } from 'lodash';
+import * as React from 'react';
+import tw from 'twin.macro';
+import { themeColor } from '../helpers';
+import { Badge } from './Badge';
+import { DeferredSpinner } from './DeferredSpinner';
+import { InteractiveIcon } from './InteractiveIcon';
+import Tooltip from './Tooltip';
+import { BareButton } from './buttons';
+import { OpenCloseIndicator } from './icons';
+import { CloseIcon } from './icons/CloseIcon';
+
+export interface FacetBoxProps {
+ ariaLabel?: string;
+ children: React.ReactNode;
+ className?: string;
+ clearIconLabel?: string;
+ count?: number;
+ countLabel?: string;
+ disabled?: boolean;
+ id?: string;
+ inner?: boolean;
+ loading?: boolean;
+ name: string;
+ onClear?: () => void;
+ onClick?: (isOpen: boolean) => void;
+ open?: boolean;
+}
+
+export function FacetBox(props: FacetBoxProps) {
+ const {
+ ariaLabel,
+ children,
+ className,
+ clearIconLabel,
+ count,
+ countLabel,
+ disabled = false,
+ id: idProp,
+ inner = false,
+ loading = false,
+ name,
+ onClear,
+ onClick,
+ open = false,
+ } = props;
+
+ const clearable = !disabled && Boolean(onClear) && count;
+ const counter = count ?? 0;
+ const expandable = !disabled && Boolean(onClick);
+ const id = React.useMemo(() => idProp ?? uniqueId('filter-facet-'), [idProp]);
+
+ return (
+ <Accordion className={classNames(className, { open })} inner={inner} role="listitem">
+ <Header>
+ <ChevronAndTitle
+ aria-controls={`${id}-panel`}
+ aria-disabled={!expandable}
+ aria-expanded={open}
+ aria-label={ariaLabel ?? name}
+ expandable={expandable}
+ id={`${id}-header`}
+ onClick={() => {
+ if (!disabled) {
+ onClick?.(!open);
+ }
+ }}
+ >
+ {expandable && <OpenCloseIndicator aria-hidden={true} open={open} />}
+
+ <HeaderTitle disabled={disabled}>{name}</HeaderTitle>
+ </ChevronAndTitle>
+
+ {<DeferredSpinner loading={loading} />}
+
+ {counter > 0 && (
+ <BadgeAndIcons>
+ <Badge title={countLabel} variant="counter">
+ {counter}
+ </Badge>
+
+ {clearable && (
+ <Tooltip overlay={clearIconLabel}>
+ <ClearIcon
+ Icon={CloseIcon}
+ aria-label={clearIconLabel ?? ''}
+ onClick={onClear}
+ size="small"
+ />
+ </Tooltip>
+ )}
+ </BadgeAndIcons>
+ )}
+ </Header>
+
+ {open && (
+ <div aria-labelledby={`${id}-header`} id={`${id}-panel`} role="region">
+ {children}
+ </div>
+ )}
+ </Accordion>
+ );
+}
+
+const Accordion = styled.div<{
+ inner?: boolean;
+}>`
+ ${tw`sw-flex-col`};
+ ${tw`sw-flex`};
+ ${tw`sw-gap-3`};
+
+ ${({ inner }) => (inner ? tw`sw-gap-1 sw-ml-3` : '')};
+`;
+
+const BadgeAndIcons = styled.div`
+ ${tw`sw-flex`};
+ ${tw`sw-gap-2`};
+`;
+
+const ChevronAndTitle = styled(BareButton)<{
+ expandable?: boolean;
+}>`
+ ${tw`sw-flex`};
+ ${tw`sw-gap-1`};
+ ${tw`sw-h-9`};
+ ${tw`sw-items-center`};
+
+ cursor: ${({ expandable }) => (expandable ? 'pointer' : 'default')};
+`;
+
+const ClearIcon = styled(InteractiveIcon)`
+ --color: ${themeColor('dangerButton')};
+`;
+
+const Header = styled.div`
+ ${tw`sw-flex`};
+ ${tw`sw-gap-3`};
+ ${tw`sw-items-center`};
+ ${tw`sw-justify-between`};
+`;
+
+const HeaderTitle = styled.span<{
+ disabled?: boolean;
+}>`
+ ${tw`sw-body-sm-highlight`};
+
+ color: ${({ disabled }) =>
+ disabled ? themeColor('facetHeaderDisabled') : themeColor('facetHeader')};
+
+ cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'inherit')};
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import * as React from 'react';
+import tw from 'twin.macro';
+import { themeBorder, themeColor, themeContrast } from '../helpers';
+import { ButtonProps, ButtonSecondary } from './buttons';
+
+export type FacetItemProps = Omit<ButtonProps, 'name' | 'onClick'> & {
+ active?: boolean;
+ name: string;
+ onClick: (x: string, multiple?: boolean) => void;
+ stat?: React.ReactNode;
+ /** Textual version of `name` */
+ tooltip?: string;
+ value: string;
+};
+
+export function FacetItem({
+ active,
+ className,
+ disabled: disabledProp = false,
+ icon,
+ name,
+ onClick,
+ stat,
+ tooltip,
+ value,
+}: FacetItemProps) {
+ const disabled = disabledProp || (stat as number) === 0;
+
+ const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
+ event.preventDefault();
+
+ onClick(value, event.ctrlKey || event.metaKey);
+ };
+
+ return (
+ <StyledButton
+ active={active}
+ className={className}
+ data-facet={value}
+ disabled={disabled}
+ icon={icon}
+ onClick={handleClick}
+ role="listitem"
+ title={tooltip}
+ >
+ <span className="container">
+ <span className="name">{name}</span>
+ <span className="stat">{stat}</span>
+ </span>
+ </StyledButton>
+ );
+}
+
+const StyledButton = styled(ButtonSecondary)<{ active?: boolean }>`
+ ${tw`sw-body-sm`};
+ ${tw`sw-p-1`};
+ ${tw`sw-rounded-1`};
+
+ --background: ${({ active }) => (active ? themeColor('facetItemSelected') : 'transparent')};
+ --backgroundHover: ${({ active }) => (active ? themeColor('facetItemSelected') : 'transparent')};
+
+ --border: ${({ active }) =>
+ active
+ ? themeBorder('default', 'facetItemSelectedBorder')
+ : themeBorder('default', 'transparent')};
+
+ &:hover {
+ --border: ${themeBorder('default', 'facetItemSelectedBorder')};
+ }
+
+ & span.container {
+ ${tw`sw-container`};
+ ${tw`sw-flex`};
+ ${tw`sw-items-center`};
+ ${tw`sw-justify-between`};
+
+ & span.stat {
+ color: ${themeColor('facetItemLight')};
+ }
+ }
+
+ &:disabled {
+ background-color: transparent;
+ border-color: transparent;
+
+ & span.container span.stat {
+ color: ${themeContrast('buttonDisabled')};
+ }
+
+ &:hover {
+ background-color: transparent;
+ border-color: transparent;
+ }
+ }
+`;
color: ${(props) => (props.selected ? themeContrast('toggleHover') : themeContrast('toggle'))};
border: none;
height: auto;
- overflow: hidden;
${tw`sw-rounded-0`};
+ ${tw`sw-truncate`};
&:first-of-type {
${tw`sw-rounded-l-2`};
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../../helpers/testUtils';
+import { FacetBox, FacetBoxProps } from '../FacetBox';
+
+it('should render an empty disabled facet box', async () => {
+ const user = userEvent.setup();
+
+ const onClick = jest.fn();
+
+ renderComponent({ disabled: true, onClick });
+
+ expect(screen.getByRole('listitem')).toBeInTheDocument();
+
+ expect(screen.queryByRole('region')).not.toBeInTheDocument();
+
+ expect(screen.getByText('Test FacetBox')).toBeInTheDocument();
+
+ expect(screen.getByRole('button', { expanded: false })).toHaveAttribute('aria-disabled', 'true');
+
+ await user.click(screen.getByRole('button'));
+
+ expect(onClick).not.toHaveBeenCalled();
+});
+
+it('should render an inner expanded facet box with count', async () => {
+ const user = userEvent.setup();
+
+ const onClear = jest.fn();
+ const onClick = jest.fn();
+
+ renderComponent({
+ children: 'The panel',
+ count: 3,
+ inner: true,
+ onClear,
+ onClick,
+ open: true,
+ });
+
+ expect(screen.getByRole('region')).toBeInTheDocument();
+
+ expect(screen.getByRole('button', { expanded: true })).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { expanded: true }));
+
+ expect(onClick).toHaveBeenCalledWith(false);
+});
+
+function renderComponent({ children, ...props }: Partial<FacetBoxProps> = {}) {
+ return render(
+ <FacetBox name="Test FacetBox" {...props}>
+ {children}
+ </FacetBox>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../../helpers/testUtils';
+import { FacetItem, FacetItemProps } from '../FacetItem';
+
+it('should render a disabled facet item', async () => {
+ const user = userEvent.setup();
+
+ const onClick = jest.fn();
+
+ renderComponent({ disabled: true, onClick });
+
+ expect(screen.getByRole('listitem')).toHaveAttribute('aria-disabled', 'true');
+
+ await user.click(screen.getByRole('listitem'));
+
+ expect(onClick).not.toHaveBeenCalled();
+});
+
+it('should render a non-disabled facet item', async () => {
+ const user = userEvent.setup();
+
+ const onClick = jest.fn();
+
+ renderComponent({ active: true, onClick, stat: 3, value: 'foo' });
+
+ expect(screen.getByRole('listitem')).toHaveAttribute('aria-disabled', 'false');
+
+ await user.click(screen.getByRole('listitem'));
+
+ expect(onClick).toHaveBeenCalledWith('foo', false);
+
+ await user.keyboard('{Meta>}');
+ await user.click(screen.getByRole('listitem'));
+
+ expect(onClick).toHaveBeenLastCalledWith('foo', true);
+});
+
+function renderComponent(props: Partial<FacetItemProps> = {}) {
+ return render(<FacetItem name="Test facet item" onClick={jest.fn()} value="Value" {...props} />);
+}
expect(screen.getAllByRole('radio')).toHaveLength(3);
+ await user.click(screen.getByText('first'));
+
+ expect(onChange).not.toHaveBeenCalled();
+
await user.click(screen.getByText('has counter'));
expect(onChange).toHaveBeenCalledWith(3);
disabled?: boolean;
icon?: React.ReactNode;
innerRef?: React.Ref<HTMLButtonElement>;
- onClick?: VoidFunction;
+ onClick?: (event?: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => void;
preventDefault?: boolean;
reloadDocument?: LinkProps['reloadDocument'];
}
if (onClick && !disabled) {
- onClick();
+ onClick(event);
}
};
export * from './DropdownMenu';
export { DropdownToggler } from './DropdownToggler';
export * from './DuplicationsIndicator';
+export * from './FacetBox';
+export * from './FacetItem';
export { FailedQGConditionLink } from './FailedQGConditionLink';
export { FlagMessage } from './FlagMessage';
export * from './GenericAvatar';
// facets
facetHeader: COLORS.blueGrey[600],
+ facetHeaderDisabled: COLORS.blueGrey[400],
facetItemSelected: COLORS.indigo[50],
facetItemSelectedHover: COLORS.indigo[100],
facetItemSelectedBorder: primary.light,
6: '1.5rem', // 24px
7: '1.75rem', // 28px
8: '2rem', // 32px
+ 9: '2.25rem', // 36px
10: '2.5rem', // 40px
12: '3rem', // 48px
14: '3.75rem', // 60px