Skip to content

Commit

Permalink
feat(filter): create dumb filter component
Browse files Browse the repository at this point in the history
  • Loading branch information
Zachary Rose authored and Zachary Rose committed Nov 6, 2024
1 parent ecd7048 commit ce8be65
Show file tree
Hide file tree
Showing 14 changed files with 700 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/componentStyles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,5 @@
@use 'components/PinchZoom/pinchZoom';
@use 'components/Tabs/tabs';
@use 'components/SeldonImage/seldonImage';
@use 'patterns/FilterControl/filterControl';
@use 'components/Filter/filter';
71 changes: 71 additions & 0 deletions src/components/Filter/Filter.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Meta } from '@storybook/react';
import Filter from './Filter';
import FilterHeader from './FilterHeader';
import FilterValue from './FilterValue';

// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
const meta = {
title: 'Components/Filter',
component: Filter,
} satisfies Meta<typeof Filter>;

export default meta;

type FilterDimension = { label: string; disabled?: boolean | undefined };

type FilterType = {
label: string;
filterDimensions: FilterDimension[];
};

type PropTypes = {
filter: FilterType;
viewAllLimit: number;
isLast: boolean;
isViewingAll: boolean;
};

const filter: FilterType = {
label: 'Artists & Makers',
filterDimensions: [
{ label: 'Jimmy' },
{ label: 'Bob' },
{ label: 'Alan' },
{ label: 'Nick' },
{ label: 'Joe' },
{ label: 'Fred' },
{ label: 'Rob' },
{ label: 'Roy' },
{ label: 'disabled', disabled: true },
],
};

export const Playground = (props: PropTypes) => {
const { filter, viewAllLimit, isLast, isViewingAll } = props;
return (
<Filter key={filter.label} isLast={isLast} viewAllLimit={viewAllLimit} viewingAll={isViewingAll}>
<FilterHeader label={filter.label} />
{filter.filterDimensions.map((value: FilterDimension) => (
<FilterValue
key={value.label}
label={value.label}
onChange={(e) => {
e;
}}
inputType="checkbox"
disabled={value?.disabled}
/>
))}
</Filter>
);
};

// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
Playground.args = {
filter,
viewAllLimit: 10,
isLast: true,
isViewingAll: false,
};

Playground.argTypes = {};
53 changes: 53 additions & 0 deletions src/components/Filter/Filter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Filter from './Filter';
import { runCommonTests } from '../../utils/testUtils';
import { render, screen } from '@testing-library/react';
import FilterHeader from './FilterHeader';
import FilterValue from './FilterValue';

describe('Filter', () => {
runCommonTests(Filter, 'Filter');

it('renders the different input types of filter values', () => {
const handleChange = vi.fn();

render(
<Filter>
<FilterValue label="Filter 1" inputType="checkbox" onChange={handleChange} />
<FilterValue label="Filter 2" inputType="radio" onChange={handleChange} />
</Filter>,
);

expect(screen.getByText('Filter 1')).toBeInTheDocument();
expect(screen.getByText('Filter 2')).toBeInTheDocument();

const checkbox = screen.getByRole('checkbox');
const radio = screen.getByRole('radio');

expect(checkbox).toHaveAttribute('type', 'checkbox');
expect(radio).toHaveAttribute('type', 'radio');
});

it('renders a filter header', () => {
render(
<Filter>
<FilterHeader label="Filter Header 1" />
</Filter>,
);

expect(screen.getByText('Filter Header 1')).toBeInTheDocument();
});

it('should disable filters when disabled prop is passed', () => {
const handleChange = vi.fn();

render(
<Filter>
<FilterValue label="Filter 1" inputType="checkbox" onChange={handleChange} disabled />
</Filter>,
);

const checkbox = screen.getByRole('checkbox');
expect(screen.getByText('Filter 1')).toHaveClass('disabled-label');
expect(checkbox).toBeDisabled();
});
});
85 changes: 85 additions & 0 deletions src/components/Filter/Filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {
ComponentProps,
forwardRef,
Children,
cloneElement,
isValidElement,
ReactNode,
Dispatch,
SetStateAction,
} from 'react';
import { getCommonProps, px } from '../../utils';
import classnames from 'classnames';
import FilterHeader, { FilterHeaderProps } from './FilterHeader';
import { FilterValueProps } from './FilterValue';
import Button from '../Button/Button';
import { ButtonVariants } from '../Button/types';
import ChevronNextIcon from '../../assets/chevronNext.svg?react';

// You'll need to change the ComponentProps<"htmlelementname"> to match the top-level element of your component
export interface FilterProps extends ComponentProps<'div'> {
children: ReactNode;

// If true, do not include bottom border. True by default, do not define if you do not wish to use separators.
isLast?: boolean;

// Setter for values to display in view all
setViewAllFilter?: Dispatch<SetStateAction<ReactNode[]>>;

// Number of values to display before truncating with view all button
viewAllLimit?: number;

// Whether this is a view all filter or not
viewingAll?: boolean;
}
/**
* ## Overview
*
* Component to display filtering dimensions
*
* [Figma Link](https://www.figma.com/design/OvBXAq48blO1r4qYbeBPjW/RW---Sale-Page-(PLP)?node-id=892-71019&node-type=frame&t=AsBDn4UgUEjNUnf7-0)
*
* [Storybook Link](https://phillips-seldon.netlify.app/?path=/docs/components-filter--overview)
*/
const Filter = forwardRef<HTMLDivElement, FilterProps>(
({ className, children, isLast = true, viewAllLimit = 10, viewingAll = false, setViewAllFilter, ...props }, ref) => {
const { className: baseClassName, ...commonProps } = getCommonProps(props, 'Filter');
const childrenArray = Children.toArray(children);

return (
<div
{...commonProps}
className={classnames(baseClassName, className, {
[`${px}-has-separators`]: !isLast,
})}
{...props}
ref={ref}
>
{childrenArray.map((child, index) =>
isValidElement(child)
? child.type === FilterHeader
? cloneElement(child, { showBack: viewingAll, setViewAllFilter } as FilterHeaderProps)
: cloneElement(child, { isHidden: !viewingAll && index > viewAllLimit } as FilterValueProps)
: child,
)}
{childrenArray.length > viewAllLimit && !viewingAll ? (
<Button
variant={ButtonVariants.tertiary}
onClick={() => {
setViewAllFilter && setViewAllFilter(childrenArray);
}}
>
{`View All`}
<ChevronNextIcon className={`${baseClassName}__chevron`} />
</Button>
) : null}
</div>
);
},
);

Filter.displayName = 'Filter';

export type FilterComponent = ReturnType<typeof Filter>;

export default Filter;
51 changes: 51 additions & 0 deletions src/components/Filter/FilterHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { ComponentProps, Dispatch, SetStateAction, forwardRef } from 'react';
import { getCommonProps } from '../../utils';
import classnames from 'classnames';
import { Text, TextVariants } from '../Text';
import Button from '../Button/Button';
import { ButtonVariants } from '../Button/types';
import ChevronNextIcon from '../../assets/chevronNext.svg?react';

export interface FilterHeaderProps extends ComponentProps<'div'> {
// Text to display as the header
label: string;

// Whether the show all back button should be displayed (when viewing all filters)
showBack?: boolean;

// Setter function for info to be displayed in View All
setViewAllFilter?: Dispatch<SetStateAction<[]>>;
}
/**
* ## Overview
*
* The header of a filter
*/
const FilterHeader = forwardRef<HTMLDivElement, FilterHeaderProps>(
({ className, label, showBack = false, setViewAllFilter, ...props }, ref) => {
const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterHeader');

return (
<div {...commonProps} className={classnames(baseClassName, className)} {...props} ref={ref}>
<Text variant={TextVariants.heading4} className={`${baseClassName}__label`}>
{label}
</Text>
{showBack ? (
<Button
variant={ButtonVariants.tertiary}
onClick={() => {
setViewAllFilter && setViewAllFilter([]);
}}
>
<ChevronNextIcon className={`${baseClassName}__chevron`} />
{`Back to all`}
</Button>
) : null}
</div>
);
},
);

FilterHeader.displayName = 'FilterHeader';

export default FilterHeader;
59 changes: 59 additions & 0 deletions src/components/Filter/FilterValue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { ComponentProps, forwardRef } from 'react';
import { getCommonProps } from '../../utils';
import classnames from 'classnames';
import { Text, TextVariants } from '../Text';
import Input from '../Input/Input';

// You'll need to change the ComponentProps<"htmlelementname"> to match the top-level element of your component
export interface FilterValueProps extends Omit<ComponentProps<'div'>, 'onChange'> {
// Text to be displayed as a label for the neighboring input
label: string;

// Type of input for this filter
inputType: 'checkbox' | 'radio';

// Should this filter be hidden, most common use will be for view all truncation
isHidden?: boolean;

// Whether or not this filter is disabled
disabled?: boolean;

// Function to trigger when the state of this filter is changed
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
/**
* ## Overview
*
* A label & input for filtering criteria
*
*/
const FilterValue = forwardRef<HTMLDivElement, FilterValueProps>(
({ className, label, inputType, isHidden = false, onChange, disabled, ...props }, ref) => {
const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterValue');
const disabledLabel = disabled ? 'disabled-label' : '';
return (
<>
{isHidden ? null : (
<div {...commonProps} className={classnames(baseClassName, className)} {...props} ref={ref}>
<Text variant={TextVariants.body2} className={`${baseClassName}__label ${disabledLabel}`}>
{label}
</Text>
<Input
disabled={disabled}
type={inputType}
className={`${baseClassName}__input`}
onChange={onChange}
hideLabel={true}
size="lg"
value={label}
/>
</div>
)}
</>
);
},
);

FilterValue.displayName = 'FilterValue';

export default FilterValue;
Loading

0 comments on commit ce8be65

Please sign in to comment.