Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(filter): L3-4114 - create dumb filter component #418

Merged
merged 12 commits into from
Nov 22, 2024
Merged
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';
105 changes: 105 additions & 0 deletions src/components/Filter/Filter.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Meta } from '@storybook/react';
import Filter from './Filter';
import FilterHeader from './FilterHeader';
import FilterValue from './FilterValue';
import { useState } from 'react';

// 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; active: boolean };

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

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

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

const FilterComponent = (props: PropTypes) => {
const { filter: intialFilters, viewAllLimit, isLast, isViewingAll } = props;
const [filter, setFilter] = useState(intialFilters);

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { checked, name } = e.target;
const updatedFilterDimensions = filter.filterDimensions.map((dimension) => {
if (dimension.label === name) {
return {
...dimension,
active: checked,
};
}
return dimension;
});

const updatedFilter = {
...filter,
filterDimensions: updatedFilterDimensions,
};

setFilter(updatedFilter);
};
return (
<Filter
key={filter.label}
name={filter.label}
isLast={isLast}
viewAllLimit={viewAllLimit}
isViewingAll={isViewingAll}
>
<FilterHeader heading={filter.label} />
{filter.filterDimensions.map((value: FilterDimension) => (
<FilterValue
key={value.label}
label={value.label}
onChange={handleChange}
inputType="checkbox"
disabled={value?.disabled}
name={value.label}
isActive={value.active}
/>
))}
</Filter>
);
};

export const Playground = (props: PropTypes) => {
return <FilterComponent {...props} />;
};

// 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 = {};
61 changes: 61 additions & 0 deletions src/components/Filter/Filter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Filter from './Filter';
import { runCommonTests } from '../../utils/testUtils';
import { render, screen } from '@testing-library/react';
import FilterHeader from './FilterHeader';
import FilterValue from './FilterValue';
import { px } from '../../utils';

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

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

render(
<Filter name="test">
<FilterValue name="test[test1]" label="Filter 1" inputType="checkbox" onChange={handleChange} isActive={true} />
<FilterValue name="test[test2]" label="Filter 2" inputType="radio" onChange={handleChange} isActive={true} />
</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 name="test">
<FilterHeader heading="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 name="test">
<FilterValue
name="test[test1]"
label="Filter 1"
inputType="checkbox"
onChange={handleChange}
disabled
isActive={true}
/>
</Filter>,
);

const checkbox = screen.getByRole('checkbox');
expect(screen.getByText('Filter 1')).toHaveClass(`${px}-filter-value-disabled-label`);
expect(checkbox).toBeDisabled();
});
});
118 changes: 118 additions & 0 deletions src/components/Filter/Filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
ComponentProps,
forwardRef,
Children,
cloneElement,
isValidElement,
ReactNode,
Dispatch,
SetStateAction,
useState,
} 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;

name: string;

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

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

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

// Whether this is a view all filter or not
isViewingAll?: boolean;

// Whether this filter is being hidden. Use when sibling is in view all state.
isHidden?: 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,
name,
isLast = true,
viewAllLimit = 10,
isViewingAll = false,
isHidden = false,
setViewAllFilter,
...props
},
ref,
) => {
// filter "child" (view all state) is closing or not
const [isClosing, setIsClosing] = useState(false);

const { className: baseClassName, ...commonProps } = getCommonProps(props, 'Filter');
const childrenArray = Children.toArray(children);

const headerProps = { isViewingAll, setViewAllFilter, setIsClosing };

// this allows the component to be composable, while still passing down props from parent to child
// taking the children composed in the filter and constructing custom props based on state
const parsedFilterChildren = childrenArray.map((child, index) =>
isValidElement(child)
? child.type === FilterHeader
? cloneElement(child, headerProps as FilterHeaderProps)
: cloneElement(child, { isHidden: !isViewingAll && index > viewAllLimit } as FilterValueProps)
: child,
);

return (
<div
Zach-OfAllTrades marked this conversation as resolved.
Show resolved Hide resolved
{...commonProps}
className={classnames(baseClassName, className, {
[`${px}-has-separators`]: !isLast && !isViewingAll,
[`${px}-filter--hidden`]: isHidden,
'is-closing': isClosing,
})}
{...props}
ref={ref}
>
{/* TODO: REMOVE INLINE STYLING */}
<fieldset name={name} style={{ border: 'none', padding: 0 }}>
Zach-OfAllTrades marked this conversation as resolved.
Show resolved Hide resolved
{parsedFilterChildren}
</fieldset>
{childrenArray.length > viewAllLimit && !isViewingAll ? (
<Button
variant={ButtonVariants.tertiary}
onClick={() => {
setViewAllFilter?.(name);
}}
>
View All
<ChevronNextIcon className={`${baseClassName}__chevron`} />
</Button>
) : null}
</div>
);
},
);

Filter.displayName = 'Filter';

export type FilterComponent = ReturnType<typeof Filter>;

export default Filter;
60 changes: 60 additions & 0 deletions src/components/Filter/FilterHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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
heading: string;

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

// Setter function for name of Filter to be displayed in View All
setViewAllFilter?: Dispatch<SetStateAction<null>>;

// Setter to apply closing transition to view all filter
setIsClosing?: Dispatch<SetStateAction<boolean>>;
}
/**
* ## Overview
*
* The header of a filter
*/
const FilterHeader = forwardRef<HTMLDivElement, FilterHeaderProps>(
({ className, heading, isViewingAll = false, setViewAllFilter, setIsClosing, ...props }, ref) => {
const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterHeader');

const handleClose = () => {
setIsClosing?.(true);
setTimeout(() => {
setViewAllFilter?.(null);
setIsClosing?.(false);
// if this timeout changes, be sure to change $default-transition-duration in _filter.scss
}, 300);
};

return (
<div {...commonProps} className={classnames(baseClassName, className)} {...props} ref={ref}>
Zach-OfAllTrades marked this conversation as resolved.
Show resolved Hide resolved
<Text
variant={TextVariants.heading4}
className={`${baseClassName}__heading`}
element={(props) => <legend {...props}>{heading}</legend>}
/>
{isViewingAll ? (
<Button variant={ButtonVariants.tertiary} onClick={handleClose} className={`${baseClassName}__back`}>
<ChevronNextIcon className={`${baseClassName}__chevron`} />
Back to all
</Button>
) : null}
</div>
);
},
);

FilterHeader.displayName = 'FilterHeader';

export default FilterHeader;
Loading
Loading