From ce8be65e66423588297f8b6f1b40a0bd441aacd9 Mon Sep 17 00:00:00 2001 From: Zachary Rose Date: Wed, 6 Nov 2024 13:48:25 -0500 Subject: [PATCH 01/11] feat(filter): create dumb filter component --- src/componentStyles.scss | 2 + src/components/Filter/Filter.stories.tsx | 71 ++++++++++ src/components/Filter/Filter.test.tsx | 53 +++++++ src/components/Filter/Filter.tsx | 85 +++++++++++ src/components/Filter/FilterHeader.tsx | 51 +++++++ src/components/Filter/FilterValue.tsx | 59 ++++++++ src/components/Filter/_filter.scss | 72 ++++++++++ src/components/Filter/index.ts | 1 + src/index.ts | 2 + .../FilterControl/FilterControl.stories.tsx | 134 ++++++++++++++++++ .../FilterControl/FilterControl.test.tsx | 110 ++++++++++++++ src/patterns/FilterControl/FilterControl.tsx | 49 +++++++ .../FilterControl/_filterControl.scss | 10 ++ src/patterns/FilterControl/index.ts | 1 + 14 files changed, 700 insertions(+) create mode 100644 src/components/Filter/Filter.stories.tsx create mode 100644 src/components/Filter/Filter.test.tsx create mode 100644 src/components/Filter/Filter.tsx create mode 100644 src/components/Filter/FilterHeader.tsx create mode 100644 src/components/Filter/FilterValue.tsx create mode 100644 src/components/Filter/_filter.scss create mode 100644 src/components/Filter/index.ts create mode 100644 src/patterns/FilterControl/FilterControl.stories.tsx create mode 100644 src/patterns/FilterControl/FilterControl.test.tsx create mode 100644 src/patterns/FilterControl/FilterControl.tsx create mode 100644 src/patterns/FilterControl/_filterControl.scss create mode 100644 src/patterns/FilterControl/index.ts diff --git a/src/componentStyles.scss b/src/componentStyles.scss index a1e5849d..ff3c8ac4 100644 --- a/src/componentStyles.scss +++ b/src/componentStyles.scss @@ -54,3 +54,5 @@ @use 'components/PinchZoom/pinchZoom'; @use 'components/Tabs/tabs'; @use 'components/SeldonImage/seldonImage'; +@use 'patterns/FilterControl/filterControl'; +@use 'components/Filter/filter'; diff --git a/src/components/Filter/Filter.stories.tsx b/src/components/Filter/Filter.stories.tsx new file mode 100644 index 00000000..78f48385 --- /dev/null +++ b/src/components/Filter/Filter.stories.tsx @@ -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; + +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.filterDimensions.map((value: FilterDimension) => ( + { + e; + }} + inputType="checkbox" + disabled={value?.disabled} + /> + ))} + + ); +}; + +// 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 = {}; diff --git a/src/components/Filter/Filter.test.tsx b/src/components/Filter/Filter.test.tsx new file mode 100644 index 00000000..7808f7dd --- /dev/null +++ b/src/components/Filter/Filter.test.tsx @@ -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( + + + + , + ); + + 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( + + + , + ); + + expect(screen.getByText('Filter Header 1')).toBeInTheDocument(); + }); + + it('should disable filters when disabled prop is passed', () => { + const handleChange = vi.fn(); + + render( + + + , + ); + + const checkbox = screen.getByRole('checkbox'); + expect(screen.getByText('Filter 1')).toHaveClass('disabled-label'); + expect(checkbox).toBeDisabled(); + }); +}); diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx new file mode 100644 index 00000000..d6339bb2 --- /dev/null +++ b/src/components/Filter/Filter.tsx @@ -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>; + + // 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( + ({ className, children, isLast = true, viewAllLimit = 10, viewingAll = false, setViewAllFilter, ...props }, ref) => { + const { className: baseClassName, ...commonProps } = getCommonProps(props, 'Filter'); + const childrenArray = Children.toArray(children); + + return ( +
+ {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 ? ( + + ) : null} +
+ ); + }, +); + +Filter.displayName = 'Filter'; + +export type FilterComponent = ReturnType; + +export default Filter; diff --git a/src/components/Filter/FilterHeader.tsx b/src/components/Filter/FilterHeader.tsx new file mode 100644 index 00000000..6d9c50aa --- /dev/null +++ b/src/components/Filter/FilterHeader.tsx @@ -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>; +} +/** + * ## Overview + * + * The header of a filter + */ +const FilterHeader = forwardRef( + ({ className, label, showBack = false, setViewAllFilter, ...props }, ref) => { + const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterHeader'); + + return ( +
+ + {label} + + {showBack ? ( + + ) : null} +
+ ); + }, +); + +FilterHeader.displayName = 'FilterHeader'; + +export default FilterHeader; diff --git a/src/components/Filter/FilterValue.tsx b/src/components/Filter/FilterValue.tsx new file mode 100644 index 00000000..6a3320f7 --- /dev/null +++ b/src/components/Filter/FilterValue.tsx @@ -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, '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) => void; +} +/** + * ## Overview + * + * A label & input for filtering criteria + * + */ +const FilterValue = forwardRef( + ({ className, label, inputType, isHidden = false, onChange, disabled, ...props }, ref) => { + const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterValue'); + const disabledLabel = disabled ? 'disabled-label' : ''; + return ( + <> + {isHidden ? null : ( +
+ + {label} + + +
+ )} + + ); + }, +); + +FilterValue.displayName = 'FilterValue'; + +export default FilterValue; diff --git a/src/components/Filter/_filter.scss b/src/components/Filter/_filter.scss new file mode 100644 index 00000000..5d78a7c8 --- /dev/null +++ b/src/components/Filter/_filter.scss @@ -0,0 +1,72 @@ +@use '#scss/allPartials' as *; + +$default-transition-duration: 0.3s; +$default-easing: ease-in-out; +$chevron-scale: 0.8; + +@keyframes slide { + from { + opacity: 0; + transform: translateX(100%); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +// temp override while input being fixed +.#{$px}-input__input { + height: 20px; + width: 20px; +} + +.#{$px}-filter { + &__chevron { + transform: scale($chevron-scale); + } + + &.#{$px}-has-separators { + border-bottom: 1px solid $light-gray; + margin-bottom: $spacing-md; + padding-bottom: $spacing-sm; + } + + &.is-open { + animation: slide $default-transition-duration $default-easing forwards; + } +} + +.#{$px}-filter-value { + display: flex; + flex-direction: row; + height: 40px; + justify-content: space-between; + + &__label { + font-variation-settings: 'wght' 600; + width: 70%; + } + + .disabled-label { + color: $keyline-gray; + cursor: default; + } + + &__input { + &__wrapper { + flex-direction: row; + justify-content: flex-end; + width: 25%; + } + } +} + +.#{$px}-filter-header { + margin-bottom: $spacing-md; + + &__chevron { + transform: rotateY(180deg) scale($chevron-scale); + } +} diff --git a/src/components/Filter/index.ts b/src/components/Filter/index.ts new file mode 100644 index 00000000..2d75d126 --- /dev/null +++ b/src/components/Filter/index.ts @@ -0,0 +1 @@ +export { default as Filter, type FilterProps } from './Filter'; diff --git a/src/index.ts b/src/index.ts index e9893ea3..153a81fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,3 +82,5 @@ export * from './patterns/SaleHeaderBanner'; // utils export * from './utils/hooks'; +export * from './patterns/FilterControl'; +export * from './components/Filter'; diff --git a/src/patterns/FilterControl/FilterControl.stories.tsx b/src/patterns/FilterControl/FilterControl.stories.tsx new file mode 100644 index 00000000..a20ab22f --- /dev/null +++ b/src/patterns/FilterControl/FilterControl.stories.tsx @@ -0,0 +1,134 @@ +import { Meta } from '@storybook/react'; +import FilterControl from './FilterControl'; +import { useArgs } from '@storybook/preview-api'; +import { useRef, useState } from 'react'; +import Button from '../../components/Button/Button'; +import { Drawer } from '../../components/Drawer'; +import FilterHeader from '../../components/Filter/FilterHeader'; +import FilterValue from '../../components/Filter/FilterValue'; +import { Filter } from '../../components/Filter'; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta = { + title: 'Patterns/FilterControl', + component: FilterControl, +} satisfies Meta; + +export default meta; + +type FilterDimension = { label: string; disabled?: boolean | undefined }; + +type FilterType = { + label: string; + filterDimensions: FilterDimension[]; + inputType: 'checkbox' | 'radio'; +}; + +type PropTypes = { + isOpen: boolean; + filters: FilterType[]; + onClose: () => void; +}; + +const filters: FilterType[] = [ + { + label: 'SortBy', + inputType: 'radio', + filterDimensions: [ + { label: 'Lot Number' }, + { label: 'A-Z Artist Maker' }, + { label: 'Price Low-High' }, + { label: 'Price High - Low' }, + ], + }, + { + label: 'Artists & Makers', + inputType: 'checkbox', + filterDimensions: [ + { label: 'Jimmy' }, + { label: 'Bob' }, + { label: 'Alan' }, + { label: 'Nick' }, + { label: 'Joe' }, + { label: 'Fred' }, + { label: 'Rob' }, + { label: 'Roy' }, + { label: 'Bill' }, + { label: 'Ted' }, + { label: 'Hidden' }, + { label: 'disabled', disabled: true }, + ], + }, +]; + +const LotsWithFilter = (props: PropTypes) => { + const [results, setResults] = useState(filters[1].filterDimensions); + const doFilter = (e: React.ChangeEvent) => { + // When implementing, can add sorting functions when sortBy section present + + // Not a true filter, but this is a bare bones example how onChange can be implemented + const rule = e.target.value; + const newResults = filters[1].filterDimensions.filter((val) => val.label === rule); + setResults(newResults); + }; + return ( + <> +
    + {results.map((value: FilterDimension) => ( +
  • {value.label}
  • + ))} +
+ + + {props.filters.map((filter: FilterType, index: number) => ( + + + {filter.filterDimensions.map((value: FilterDimension) => ( + + ))} + + ))} + + + + + ); +}; + +export const Playground = (props: PropTypes) => { + const [, updateArgs] = useArgs(); + const buttonRef = useRef(null); + + const onClose = () => { + // Refocus on button after close for keyboard navigation + buttonRef.current?.focus(); + updateArgs({ ...props, isOpen: false }); + }; + + const onOpen = () => { + updateArgs({ ...props, isOpen: true }); + }; + + return ( + <> + + + + ); +}; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args +Playground.args = { + isOpen: false, + filters, +}; + +Playground.argTypes = {}; diff --git a/src/patterns/FilterControl/FilterControl.test.tsx b/src/patterns/FilterControl/FilterControl.test.tsx new file mode 100644 index 00000000..1308cabb --- /dev/null +++ b/src/patterns/FilterControl/FilterControl.test.tsx @@ -0,0 +1,110 @@ +import FilterControl from './FilterControl'; +import { runCommonTests } from '../../utils/testUtils'; +import { render, screen, fireEvent } from '@testing-library/react'; +import FilterHeader from '../../components/Filter/FilterHeader'; +import FilterValue from '../../components/Filter/FilterValue'; +import { Filter } from '../../components/Filter'; +import { px } from '../../utils'; + +const BACK_BUTTON_TEXT = 'Back to all'; +const VIEW_ALL_BUTTON_TEXT = 'View All'; + +describe('FilterControl', () => { + runCommonTests(FilterControl, 'FilterControl'); + const handleChange = vi.fn(); + const filters = Array(11).fill(0); + + it('should render the "View All" button when there are more than 10 filters', () => { + render( + + + + {filters.map((_, i) => ( + + ))} + + , + ); + + expect(screen.getByTestId('button')).toBeInTheDocument(); + }); + + it('should hide values over viewAllLimit and render those under', () => { + render( + + + + {filters.map((_, i) => ( + + ))} + + , + ); + + const values = screen.getAllByTestId('filter-value'); + expect(values).toHaveLength(3); + }); + + it('render the child filter', () => { + render( + + + + {filters.map((_, i) => ( + + ))} + + + + + + , + ); + + const filter2Value = screen.getByRole('radio'); + + // View All button + fireEvent.click(screen.getByTestId('button')); + + // Back button should render + expect(screen.getByText(BACK_BUTTON_TEXT)).toBeInTheDocument(); + // Second filter should no longer be displayed + expect(filter2Value).not.toBeInTheDocument(); + }); + + it('should handle the back button click', () => { + render( + + + + {filters.map((_, i) => ( + + ))} + + , + ); + + fireEvent.click(screen.getByText(VIEW_ALL_BUTTON_TEXT)); + fireEvent.click(screen.getByText(BACK_BUTTON_TEXT)); + + expect(screen.getByText(VIEW_ALL_BUTTON_TEXT)).toBeInTheDocument(); + }); + + it('renders multiple filters and has separators', () => { + render( + + + + + + + + , + ); + + const filters = screen.getAllByTestId('filter'); + + expect(filters[0]).toHaveClass(`${px}-has-separators`); + expect(filters[1]).not.toHaveClass(`${px}-has-separators`); + }); +}); diff --git a/src/patterns/FilterControl/FilterControl.tsx b/src/patterns/FilterControl/FilterControl.tsx new file mode 100644 index 00000000..4318ffce --- /dev/null +++ b/src/patterns/FilterControl/FilterControl.tsx @@ -0,0 +1,49 @@ +import React, { ComponentProps, forwardRef, useState, Children, cloneElement, ReactNode } from 'react'; +import { getCommonProps } from '../../utils'; +import classnames from 'classnames'; +import Filter, { FilterComponent, FilterProps } from '../../components/Filter/Filter'; + +export interface FilterControlProps extends ComponentProps<'div'> { + // This is a composable component that is expecting a Filter component or an array of + children: FilterComponent | FilterComponent[]; +} +/** + * ## Overview + * + * A container for filters that controls the state of parent and child filters + * + * [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/patterns-filtercontrol--overview) + */ +const FilterControl = forwardRef(({ className, children, ...props }, ref) => { + const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterControl'); + const [viewAllFilter, setViewAllFilter] = useState([]); + + return ( +
+ {viewAllFilter.length ? ( + + {viewAllFilter} + + ) : ( + Children.map(children, (childElement) => + React.isValidElement(childElement) + ? cloneElement(childElement, { + setViewAllFilter, + className: classnames(childElement.props.className, 'is-open'), + } as FilterProps) + : childElement, + ) + )} +
+ ); +}); + +FilterControl.displayName = 'FilterControl'; + +export default FilterControl; diff --git a/src/patterns/FilterControl/_filterControl.scss b/src/patterns/FilterControl/_filterControl.scss new file mode 100644 index 00000000..32a7dce4 --- /dev/null +++ b/src/patterns/FilterControl/_filterControl.scss @@ -0,0 +1,10 @@ +@use '#scss/allPartials' as *; + +.#{$px}-filter-control { + overflow: scroll; + width: 90%; + + &::-webkit-scrollbar { + display: none; + } +} diff --git a/src/patterns/FilterControl/index.ts b/src/patterns/FilterControl/index.ts new file mode 100644 index 00000000..cb562be9 --- /dev/null +++ b/src/patterns/FilterControl/index.ts @@ -0,0 +1 @@ +export { default as FilterControl, type FilterControlProps } from './FilterControl'; From f48ad977c254661961a89b6e9799f8c212b7d8c7 Mon Sep 17 00:00:00 2001 From: Zachary Rose Date: Mon, 18 Nov 2024 13:06:54 -0500 Subject: [PATCH 02/11] feat(filter): L3-4114 updates per code review; working filter in story --- src/components/Filter/Filter.stories.tsx | 68 ++++-- src/components/Filter/Filter.test.tsx | 24 +- src/components/Filter/Filter.tsx | 63 ++++-- src/components/Filter/FilterHeader.tsx | 41 ++-- src/components/Filter/FilterValue.tsx | 75 +++++-- src/components/Filter/_filter.scss | 43 +++- .../FilterControl/FilterControl.stories.tsx | 205 ++++++++++++++---- .../FilterControl/FilterControl.test.tsx | 114 +++++++--- src/patterns/FilterControl/FilterControl.tsx | 58 ++--- src/patterns/FilterControl/utils.tsx | 138 ++++++++++++ 10 files changed, 649 insertions(+), 180 deletions(-) create mode 100644 src/patterns/FilterControl/utils.tsx diff --git a/src/components/Filter/Filter.stories.tsx b/src/components/Filter/Filter.stories.tsx index 78f48385..9d9d9d0f 100644 --- a/src/components/Filter/Filter.stories.tsx +++ b/src/components/Filter/Filter.stories.tsx @@ -2,6 +2,7 @@ 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 = { @@ -11,10 +12,11 @@ const meta = { export default meta; -type FilterDimension = { label: string; disabled?: boolean | undefined }; +type FilterDimension = { label: string; disabled?: boolean | undefined; active: boolean }; type FilterType = { label: string; + id: string; filterDimensions: FilterDimension[]; }; @@ -27,39 +29,71 @@ type PropTypes = { const filter: FilterType = { label: 'Artists & Makers', + id: 'makers', filterDimensions: [ - { label: 'Jimmy' }, - { label: 'Bob' }, - { label: 'Alan' }, - { label: 'Nick' }, - { label: 'Joe' }, - { label: 'Fred' }, - { label: 'Rob' }, - { label: 'Roy' }, - { label: 'disabled', disabled: true }, + { 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 }, ], }; -export const Playground = (props: PropTypes) => { - const { filter, viewAllLimit, isLast, isViewingAll } = props; +const FilterComponent = (props: PropTypes) => { + const { filter: intialFilters, viewAllLimit, isLast, isViewingAll } = props; + const [filter, setFilter] = useState(intialFilters); + + const handleChange = (e: React.ChangeEvent) => { + 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.filterDimensions.map((value: FilterDimension) => ( { - e; - }} + onChange={handleChange} inputType="checkbox" disabled={value?.disabled} + name={value.label} + isActive={value.active} /> ))} ); }; +export const Playground = (props: PropTypes) => { + return ; +}; + // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args Playground.args = { filter, diff --git a/src/components/Filter/Filter.test.tsx b/src/components/Filter/Filter.test.tsx index 7808f7dd..7555730b 100644 --- a/src/components/Filter/Filter.test.tsx +++ b/src/components/Filter/Filter.test.tsx @@ -3,6 +3,7 @@ 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'); @@ -11,9 +12,9 @@ describe('Filter', () => { const handleChange = vi.fn(); render( - - - + + + , ); @@ -29,8 +30,8 @@ describe('Filter', () => { it('renders a filter header', () => { render( - - + + , ); @@ -41,13 +42,20 @@ describe('Filter', () => { const handleChange = vi.fn(); render( - - + + , ); const checkbox = screen.getByRole('checkbox'); - expect(screen.getByText('Filter 1')).toHaveClass('disabled-label'); + expect(screen.getByText('Filter 1')).toHaveClass(`${px}-filter-value-disabled-label`); expect(checkbox).toBeDisabled(); }); }); diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx index d6339bb2..baa52479 100644 --- a/src/components/Filter/Filter.tsx +++ b/src/components/Filter/Filter.tsx @@ -7,6 +7,7 @@ import { ReactNode, Dispatch, SetStateAction, + useState, } from 'react'; import { getCommonProps, px } from '../../utils'; import classnames from 'classnames'; @@ -20,17 +21,22 @@ import ChevronNextIcon from '../../assets/chevronNext.svg?react'; 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. + 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>; + setViewAllFilter?: Dispatch>; // Number of values to display before truncating with view all button viewAllLimit?: number; // Whether this is a view all filter or not - viewingAll?: boolean; + isViewingAll?: boolean; + + // Whether this filter is being hidden. Use when sibling is in view all state. + isHidden?: boolean; } /** * ## Overview @@ -42,34 +48,61 @@ export interface FilterProps extends ComponentProps<'div'> { * [Storybook Link](https://phillips-seldon.netlify.app/?path=/docs/components-filter--overview) */ const Filter = forwardRef( - ({ className, children, isLast = true, viewAllLimit = 10, viewingAll = false, setViewAllFilter, ...props }, ref) => { + ( + { + 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 (
- {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 ? ( + {/* TODO: REMOVE INLINE STYLING */} +
+ {parsedFilterChildren} +
+ {childrenArray.length > viewAllLimit && !isViewingAll ? ( ) : null} diff --git a/src/components/Filter/FilterHeader.tsx b/src/components/Filter/FilterHeader.tsx index 6d9c50aa..aaa69be4 100644 --- a/src/components/Filter/FilterHeader.tsx +++ b/src/components/Filter/FilterHeader.tsx @@ -8,13 +8,16 @@ import ChevronNextIcon from '../../assets/chevronNext.svg?react'; export interface FilterHeaderProps extends ComponentProps<'div'> { // Text to display as the header - label: string; + heading: string; // Whether the show all back button should be displayed (when viewing all filters) - showBack?: boolean; + isViewingAll?: boolean; - // Setter function for info to be displayed in View All - setViewAllFilter?: Dispatch>; + // Setter function for name of Filter to be displayed in View All + setViewAllFilter?: Dispatch>; + + // Setter to apply closing transition to view all filter + setIsClosing?: Dispatch>; } /** * ## Overview @@ -22,23 +25,29 @@ export interface FilterHeaderProps extends ComponentProps<'div'> { * The header of a filter */ const FilterHeader = forwardRef( - ({ className, label, showBack = false, setViewAllFilter, ...props }, ref) => { + ({ 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 (
- - {label} - - {showBack ? ( - ) : null}
diff --git a/src/components/Filter/FilterValue.tsx b/src/components/Filter/FilterValue.tsx index 6a3320f7..a483a10b 100644 --- a/src/components/Filter/FilterValue.tsx +++ b/src/components/Filter/FilterValue.tsx @@ -1,17 +1,22 @@ import { ComponentProps, forwardRef } from 'react'; -import { getCommonProps } from '../../utils'; +import { getCommonProps, px } from '../../utils'; import classnames from 'classnames'; import { Text, TextVariants } from '../Text'; -import Input from '../Input/Input'; +import Input, { InputProps } 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, 'onChange'> { +export interface FilterValueProps extends Omit, 'onChange' | 'value'> { + name: string; + // Text to be displayed as a label for the neighboring input label: string; // Type of input for this filter inputType: 'checkbox' | 'radio'; + // Selected or not + isActive: boolean; + // Should this filter be hidden, most common use will be for view all truncation isHidden?: boolean; @@ -20,6 +25,8 @@ export interface FilterValueProps extends Omit, 'onChange' // Function to trigger when the state of this filter is changed onChange: (e: React.ChangeEvent) => void; + + inputProps?: InputProps; } /** * ## Overview @@ -28,28 +35,52 @@ export interface FilterValueProps extends Omit, 'onChange' * */ const FilterValue = forwardRef( - ({ className, label, inputType, isHidden = false, onChange, disabled, ...props }, ref) => { + ( + { + className, + name, + label, + inputType = 'checkbox', + isHidden = false, + onChange, + disabled, + isActive, + inputProps, + ...props + }, + ref, + ) => { const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterValue'); - const disabledLabel = disabled ? 'disabled-label' : ''; + const disabledClass = disabled ? `${baseClassName}-disabled-label` : ''; return ( - <> - {isHidden ? null : ( -
- +
+ ( + - -
- )} - + + )} + /> + +
); }, ); diff --git a/src/components/Filter/_filter.scss b/src/components/Filter/_filter.scss index 5d78a7c8..6e94bb95 100644 --- a/src/components/Filter/_filter.scss +++ b/src/components/Filter/_filter.scss @@ -4,7 +4,7 @@ $default-transition-duration: 0.3s; $default-easing: ease-in-out; $chevron-scale: 0.8; -@keyframes slide { +@keyframes slide-in { from { opacity: 0; transform: translateX(100%); @@ -16,9 +16,22 @@ $chevron-scale: 0.8; } } +@keyframes slide-out { + from { + opacity: 1; + transform: translateX(0); + } + + to { + opacity: 0; + transform: translateX(100%); + } +} + // temp override while input being fixed .#{$px}-input__input { height: 20px; + margin-top: 5px; width: 20px; } @@ -33,8 +46,16 @@ $chevron-scale: 0.8; padding-bottom: $spacing-sm; } - &.is-open { - animation: slide $default-transition-duration $default-easing forwards; + &--hidden { + @include hidden; + } + + &.is-opening { + animation: slide-in $default-transition-duration $default-easing forwards; + } + + &.is-closing { + animation: slide-out $default-transition-duration $default-easing forwards; } } @@ -47,11 +68,19 @@ $chevron-scale: 0.8; &__label { font-variation-settings: 'wght' 600; width: 70%; + + &:hover { + cursor: pointer; + } } - .disabled-label { + &-disabled-label { color: $keyline-gray; - cursor: default; + cursor: default !important; + } + + &--hidden { + @include hidden; } &__input { @@ -69,4 +98,8 @@ $chevron-scale: 0.8; &__chevron { transform: rotateY(180deg) scale($chevron-scale); } + + &__back { + margin-top: $spacing-sm; + } } diff --git a/src/patterns/FilterControl/FilterControl.stories.tsx b/src/patterns/FilterControl/FilterControl.stories.tsx index a20ab22f..09c68822 100644 --- a/src/patterns/FilterControl/FilterControl.stories.tsx +++ b/src/patterns/FilterControl/FilterControl.stories.tsx @@ -1,12 +1,13 @@ import { Meta } from '@storybook/react'; import FilterControl from './FilterControl'; import { useArgs } from '@storybook/preview-api'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import Button from '../../components/Button/Button'; import { Drawer } from '../../components/Drawer'; import FilterHeader from '../../components/Filter/FilterHeader'; import FilterValue from '../../components/Filter/FilterValue'; import { Filter } from '../../components/Filter'; +import { exampleAuctionLots, lotType } from './utils'; // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction const meta = { @@ -16,10 +17,15 @@ const meta = { export default meta; -type FilterDimension = { label: string; disabled?: boolean | undefined }; +type FilterDimension = { + label: string; + active: boolean; + disabled?: boolean | undefined; +}; type FilterType = { label: string; + id: string; filterDimensions: FilterDimension[]; inputType: 'checkbox' | 'radio'; }; @@ -30,66 +36,189 @@ type PropTypes = { onClose: () => void; }; +const FILTER_KEYS = { + sortBy: 'sortBy', + makers: 'makers', + collections: 'collections', +}; + const filters: FilterType[] = [ { label: 'SortBy', + id: FILTER_KEYS.sortBy, inputType: 'radio', filterDimensions: [ - { label: 'Lot Number' }, - { label: 'A-Z Artist Maker' }, - { label: 'Price Low-High' }, - { label: 'Price High - Low' }, + { label: 'Lot Number', active: true }, + { label: 'A-Z Artist Maker', active: false }, + { label: 'Price Low-High', active: false }, + { label: 'Price High - Low', active: false }, ], }, { label: 'Artists & Makers', + id: FILTER_KEYS.makers, inputType: 'checkbox', - filterDimensions: [ - { label: 'Jimmy' }, - { label: 'Bob' }, - { label: 'Alan' }, - { label: 'Nick' }, - { label: 'Joe' }, - { label: 'Fred' }, - { label: 'Rob' }, - { label: 'Roy' }, - { label: 'Bill' }, - { label: 'Ted' }, - { label: 'Hidden' }, - { label: 'disabled', disabled: true }, - ], + filterDimensions: [], + }, + { + label: 'Collections', + id: FILTER_KEYS.collections, + inputType: 'checkbox', + filterDimensions: [], }, ]; +const buildFilters = () => { + const builtFilters = filters; + const makerSet = new Set(); + const collectionSet = new Set(); + exampleAuctionLots.forEach((lot) => { + makerSet.add(lot.maker); + collectionSet.add(lot.collection); + }); + + // Artists (starting off with John Doe select, disabling last lot) + builtFilters[1].filterDimensions = Array.from(makerSet).map((maker: string, i: number) => { + return { label: maker, active: i === 0, disabled: i === 11 }; + }); + builtFilters[2].filterDimensions = Array.from(collectionSet).map((collection: string) => { + return { label: collection, active: false }; + }); + + return builtFilters; +}; + const LotsWithFilter = (props: PropTypes) => { - const [results, setResults] = useState(filters[1].filterDimensions); - const doFilter = (e: React.ChangeEvent) => { - // When implementing, can add sorting functions when sortBy section present - - // Not a true filter, but this is a bare bones example how onChange can be implemented - const rule = e.target.value; - const newResults = filters[1].filterDimensions.filter((val) => val.label === rule); - setResults(newResults); + const initialFilters = buildFilters(); + const [results, setResults] = useState(exampleAuctionLots); + const [filters, setFilters] = useState(initialFilters); + const [filterRules, setFilterRules] = useState>>(new Map()); + + const updateFilters = (filterId: string, checked: boolean, name: string) => { + const updatedFilters = filters.map((filter) => { + if (filter.id === filterId) { + const updatedFilterDimensions = filter.filterDimensions.map((dimension) => { + if (dimension.label === name) { + return { + ...dimension, + active: checked, + }; + } else if (filterId === FILTER_KEYS.sortBy) { + return { + ...dimension, + active: false, + }; + } + return dimension; + }); + + return { + ...filter, + filterDimensions: updatedFilterDimensions, + }; + } + return filter; + }); + + setFilters(updatedFilters); }; + + const updateFilterRules = (filterId: string, checked: boolean, name: string) => { + const newFilterRules = new Map(filterRules); + const rule = newFilterRules.get(filterId) ?? new Set(); + + if (checked) { + rule.add(name); + } else { + rule.delete(name); + } + + newFilterRules.set(filterId, rule); + setFilterRules(newFilterRules); + return newFilterRules; + }; + + useEffect(() => { + const initialActiveFilters = initialFilters + .map((filter) => { + const activeDimensions = filter.filterDimensions.filter((dimension) => dimension.active); + const initialSet = new Set(); + activeDimensions.forEach((dimension) => { + initialSet.add(dimension.label); + }); + + return activeDimensions.length ? { key: filter.id, dimSet: initialSet } : null; + }) + .filter((result) => result !== null); + + const initialFilterRules = new Map(); + initialActiveFilters.forEach((filter) => { + initialFilterRules.set(filter?.key, filter?.dimSet); + }); + + setFilterRules(initialFilterRules); + filterResults(initialFilterRules); + }, [initialFilters]); + + const filterResults = (newFilterRules: Map>) => { + const filterResults: lotType[] = []; + const selectedMakers = newFilterRules.get(FILTER_KEYS.makers) ?? new Set(); + const selectedCollections = newFilterRules.get(FILTER_KEYS.collections); + + exampleAuctionLots.forEach((lot) => { + const matchesMaker = selectedMakers === undefined || selectedMakers?.size === 0 || selectedMakers?.has(lot.maker); + const matchesCollection = + selectedCollections === undefined || + selectedCollections?.size === 0 || + selectedCollections?.has(lot.collection); + + if (matchesMaker && matchesCollection) { + filterResults.push(lot); + } + }); + + setResults(filterResults); + }; + + const handleFilter = (e: React.ChangeEvent, filterId: string) => { + const { name, checked } = e.target; + + const newFilterRules = updateFilterRules(filterId, checked, name); + updateFilters(filterId, checked, name); + filterResults(newFilterRules); + }; + return ( <> -
    - {results.map((value: FilterDimension) => ( -
  • {value.label}
  • +
    + {results.map((lot: lotType) => ( +
    + +
    + {lot.lotNumber} +
    +
    + {lot.maker} +
    +
    {lot.title}
    +
    {`$${lot.price}`}
    +
    ))} -
+
- - {props.filters.map((filter: FilterType, index: number) => ( - - - {filter.filterDimensions.map((value: FilterDimension) => ( + + {filters.map((filter: FilterType, index: number) => ( + + + {Array.from(filter.filterDimensions).map((value: FilterDimension) => ( handleFilter(e, filter.id)} inputType={filter.inputType} disabled={value?.disabled} + name={value.label} + isActive={value.active} /> ))} diff --git a/src/patterns/FilterControl/FilterControl.test.tsx b/src/patterns/FilterControl/FilterControl.test.tsx index 1308cabb..3ebedaca 100644 --- a/src/patterns/FilterControl/FilterControl.test.tsx +++ b/src/patterns/FilterControl/FilterControl.test.tsx @@ -13,14 +13,22 @@ describe('FilterControl', () => { runCommonTests(FilterControl, 'FilterControl'); const handleChange = vi.fn(); const filters = Array(11).fill(0); + const mockAction = '/dummy'; it('should render the "View All" button when there are more than 10 filters', () => { render( - - - + + + {filters.map((_, i) => ( - + ))} , @@ -29,56 +37,85 @@ describe('FilterControl', () => { expect(screen.getByTestId('button')).toBeInTheDocument(); }); - it('should hide values over viewAllLimit and render those under', () => { + it('should hide values over viewAllLimit and display those under', () => { render( - - - + + + {filters.map((_, i) => ( - + ))} , ); const values = screen.getAllByTestId('filter-value'); - expect(values).toHaveLength(3); + expect(values[2]).toBeVisible(); + expect(values[3]).toHaveClass(`${px}-input__label--hidden`); }); it('render the child filter', () => { render( - - - + + + {filters.map((_, i) => ( - + ))} - - - + + + , ); - const filter2Value = screen.getByRole('radio'); + const filter2Value = screen.getAllByTestId('filter')[1]; // View All button fireEvent.click(screen.getByTestId('button')); - // Back button should render - expect(screen.getByText(BACK_BUTTON_TEXT)).toBeInTheDocument(); + // Back button only for first filter should be displayed + expect(screen.getAllByText(BACK_BUTTON_TEXT)[0]).toBeVisible(); + // Second filter should no longer be displayed - expect(filter2Value).not.toBeInTheDocument(); + expect(filter2Value).toHaveClass(`${px}-filter--hidden`); }); it('should handle the back button click', () => { render( - - - + + + {filters.map((_, i) => ( - + ))} , @@ -87,17 +124,32 @@ describe('FilterControl', () => { fireEvent.click(screen.getByText(VIEW_ALL_BUTTON_TEXT)); fireEvent.click(screen.getByText(BACK_BUTTON_TEXT)); - expect(screen.getByText(VIEW_ALL_BUTTON_TEXT)).toBeInTheDocument(); + //wait for transition to be done + setTimeout(() => { + expect(screen.getByText(VIEW_ALL_BUTTON_TEXT)).toBeInTheDocument(); + }, 500); }); it('renders multiple filters and has separators', () => { render( - - - + + + - - + + , ); diff --git a/src/patterns/FilterControl/FilterControl.tsx b/src/patterns/FilterControl/FilterControl.tsx index 4318ffce..405f0148 100644 --- a/src/patterns/FilterControl/FilterControl.tsx +++ b/src/patterns/FilterControl/FilterControl.tsx @@ -1,11 +1,13 @@ -import React, { ComponentProps, forwardRef, useState, Children, cloneElement, ReactNode } from 'react'; +import React, { ComponentProps, forwardRef, useState, Children, cloneElement } from 'react'; import { getCommonProps } from '../../utils'; import classnames from 'classnames'; -import Filter, { FilterComponent, FilterProps } from '../../components/Filter/Filter'; +import { FilterComponent, FilterProps } from '../../components/Filter/Filter'; export interface FilterControlProps extends ComponentProps<'div'> { // This is a composable component that is expecting a Filter component or an array of children: FilterComponent | FilterComponent[]; + + action: string; } /** * ## Overview @@ -16,33 +18,33 @@ export interface FilterControlProps extends ComponentProps<'div'> { * * [Storybook Link](https://phillips-seldon.netlify.app/?path=/docs/patterns-filtercontrol--overview) */ -const FilterControl = forwardRef(({ className, children, ...props }, ref) => { - const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterControl'); - const [viewAllFilter, setViewAllFilter] = useState([]); +const FilterControl = forwardRef( + ({ className, children, action, ...props }, ref) => { + const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterControl'); + + // this state variable will be set to the filter name when view all has been clicked, + // and null as default and when back is clicked + const [viewAllFilter, setViewAllFilter] = useState(null); + const isViewAllSet = viewAllFilter?.length; + + const parsedChildren = Children.map(children, (childElement) => + React.isValidElement(childElement) + ? cloneElement(childElement, { + setViewAllFilter, + isHidden: !isViewAllSet ? false : viewAllFilter !== childElement.props.name, + isViewingAll: isViewAllSet, + className: isViewAllSet && classnames(childElement.props.className, 'is-opening'), + } as FilterProps) + : childElement, + ); - return ( -
- {viewAllFilter.length ? ( - - {viewAllFilter} - - ) : ( - Children.map(children, (childElement) => - React.isValidElement(childElement) - ? cloneElement(childElement, { - setViewAllFilter, - className: classnames(childElement.props.className, 'is-open'), - } as FilterProps) - : childElement, - ) - )} -
- ); -}); + return ( +
+
{parsedChildren}
+
+ ); + }, +); FilterControl.displayName = 'FilterControl'; diff --git a/src/patterns/FilterControl/utils.tsx b/src/patterns/FilterControl/utils.tsx new file mode 100644 index 00000000..b6d17a89 --- /dev/null +++ b/src/patterns/FilterControl/utils.tsx @@ -0,0 +1,138 @@ +export type lotType = { + id: string; + lotNumber: number; + title: string; + imageSrc: string; + maker: string; + price: number; + collection: string; +}; + +export const exampleAuctionLots: lotType[] = [ + { + id: 'lot-001', + lotNumber: 1, + title: 'Sunset Over the Hills', + imageSrc: 'https://via.placeholder.com/150', + maker: 'John Doe', + price: 120000, + collection: 'Modern Landscape Collection', + }, + { + id: 'lot-002', + lotNumber: 2, + title: 'The Lady in Blue', + imageSrc: 'https://via.placeholder.com/150', + maker: 'Jane Smith', + price: 80000, + collection: 'Classical Portrait Collection', + }, + { + id: 'lot-003', + lotNumber: 3, + title: 'Chaos in Motion', + imageSrc: 'https://via.placeholder.com/150', + maker: 'Emily Clark', + price: 95000, + collection: 'Abstract Art Series', + }, + { + id: 'lot-004', + lotNumber: 4, + title: 'Bountiful Harvest', + imageSrc: 'https://via.placeholder.com/150', + maker: 'Michael Johnson', + price: 65000, + collection: 'Still Life Masterpieces', + }, + { + id: 'lot-005', + lotNumber: 5, + title: 'The Galloping Stallion', + imageSrc: 'https://via.placeholder.com/150', + maker: 'Sarah Lee', + price: 130000, + collection: 'Sculptural Works Collection', + }, + { + id: 'lot-006', + lotNumber: 6, + title: 'Crashing Waves', + imageSrc: 'https://via.placeholder.com/150', + maker: 'David Wang', + price: 95000, + collection: 'Coastal Views Series', + }, + { + id: 'lot-007', + lotNumber: 7, + title: 'Urban Dreams', + imageSrc: 'https://via.placeholder.com/150', + maker: 'Olivia Brown', + price: 105000, + collection: 'Mixed Media Exploration', + }, + { + id: 'lot-008', + lotNumber: 8, + title: 'Lion King at Dusk', + imageSrc: 'https://via.placeholder.com/150', + maker: 'Christopher King', + price: 140000, + collection: 'Wildlife Wonders', + }, + { + id: 'lot-009', + lotNumber: 9, + title: 'City at Night', + imageSrc: 'https://via.placeholder.com/150', + maker: 'Isabella Lopez', + price: 78000, + collection: 'Urban Landscapes', + }, + { + id: 'lot-010', + lotNumber: 10, + title: 'Golden Floral Vases', + imageSrc: 'https://via.placeholder.com/150', + maker: 'Liam Harris', + price: 55000, + collection: 'Porcelain Artifacts', + }, + { + id: 'lot-011', + lotNumber: 11, + title: 'Fluid Form', + imageSrc: 'https://via.placeholder.com/150', + maker: 'Ethan Martinez', + price: 170000, + collection: 'Modern Sculpture Series', + }, + { + id: 'lot-012', + lotNumber: 12, + title: 'The Last Charge', + imageSrc: 'https://via.placeholder.com/150', + maker: 'Charles Thomas', + price: 220000, + collection: 'Historical War Art', + }, + { + id: 'lot-0013', + lotNumber: 13, + title: 'Eternal Moment', + imageSrc: 'https://via.placeholder.com/150', + maker: 'John Doe', + price: 140000, + collection: 'Modern Landscape Collection', + }, + { + id: 'lot-014', + lotNumber: 14, + title: 'The Balance of Nature', + imageSrc: 'https://via.placeholder.com/150', + maker: 'Isabella Lopez', + price: 200000, + collection: 'Urban Landscapes', + }, +]; From 54aae485c96ac0a6e595dcdbf26254cac46f83fb Mon Sep 17 00:00:00 2001 From: Zachary Rose Date: Tue, 19 Nov 2024 07:56:10 -0500 Subject: [PATCH 03/11] feat(filter): L3-4114 more tweaks per code review, inputProp implementation, Remix Form acceptance --- src/components/Filter/Filter.stories.tsx | 17 ++-- src/components/Filter/Filter.test.tsx | 41 ++++++--- src/components/Filter/Filter.tsx | 3 +- src/components/Filter/FilterValue.tsx | 42 +++------ src/components/Filter/_filter.scss | 5 ++ .../FilterControl/FilterControl.stories.tsx | 19 ++-- .../FilterControl/FilterControl.test.tsx | 88 ++++++++++++------- src/patterns/FilterControl/FilterControl.tsx | 13 +-- 8 files changed, 134 insertions(+), 94 deletions(-) diff --git a/src/components/Filter/Filter.stories.tsx b/src/components/Filter/Filter.stories.tsx index 9d9d9d0f..68fff9c9 100644 --- a/src/components/Filter/Filter.stories.tsx +++ b/src/components/Filter/Filter.stories.tsx @@ -47,8 +47,8 @@ const FilterComponent = (props: PropTypes) => { const { filter: intialFilters, viewAllLimit, isLast, isViewingAll } = props; const [filter, setFilter] = useState(intialFilters); - const handleChange = (e: React.ChangeEvent) => { - const { checked, name } = e.target; + const handleChange = (e: React.ChangeEvent) => { + const { checked, name } = e.target as HTMLInputElement; const updatedFilterDimensions = filter.filterDimensions.map((dimension) => { if (dimension.label === name) { return { @@ -78,12 +78,15 @@ const FilterComponent = (props: PropTypes) => { {filter.filterDimensions.map((value: FilterDimension) => ( ))}
diff --git a/src/components/Filter/Filter.test.tsx b/src/components/Filter/Filter.test.tsx index 7555730b..f5d83fcb 100644 --- a/src/components/Filter/Filter.test.tsx +++ b/src/components/Filter/Filter.test.tsx @@ -13,13 +13,31 @@ describe('Filter', () => { render( - - + + , ); - expect(screen.getByText('Filter 1')).toBeInTheDocument(); - expect(screen.getByText('Filter 2')).toBeInTheDocument(); + expect(screen.getAllByText('Filter 1')[0]).toBeInTheDocument(); + expect(screen.getAllByText('Filter 2')[0]).toBeInTheDocument(); const checkbox = screen.getByRole('checkbox'); const radio = screen.getByRole('radio'); @@ -44,18 +62,21 @@ describe('Filter', () => { render( , ); const checkbox = screen.getByRole('checkbox'); - expect(screen.getByText('Filter 1')).toHaveClass(`${px}-filter-value-disabled-label`); + expect(screen.getByTestId('text')).toHaveClass(`${px}-filter-value-disabled-label`); expect(checkbox).toBeDisabled(); }); }); diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx index baa52479..d65f6608 100644 --- a/src/components/Filter/Filter.tsx +++ b/src/components/Filter/Filter.tsx @@ -91,8 +91,7 @@ const Filter = forwardRef( {...props} ref={ref} > - {/* TODO: REMOVE INLINE STYLING */} -
+
{parsedFilterChildren}
{childrenArray.length > viewAllLimit && !isViewingAll ? ( diff --git a/src/components/Filter/FilterValue.tsx b/src/components/Filter/FilterValue.tsx index a483a10b..11b6b58d 100644 --- a/src/components/Filter/FilterValue.tsx +++ b/src/components/Filter/FilterValue.tsx @@ -6,27 +6,13 @@ import Input, { InputProps } 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, 'onChange' | 'value'> { - name: string; - - // Text to be displayed as a label for the neighboring input - label: string; - // Type of input for this filter inputType: 'checkbox' | 'radio'; - // Selected or not - isActive: boolean; - // 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) => void; - - inputProps?: InputProps; + inputProps: InputProps; } /** * ## Overview @@ -38,20 +24,20 @@ const FilterValue = forwardRef( ( { className, - name, - label, + // name, + // label, inputType = 'checkbox', isHidden = false, - onChange, - disabled, - isActive, + // onChange, + // disabled, + // isActive, inputProps, ...props }, ref, ) => { const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterValue'); - const disabledClass = disabled ? `${baseClassName}-disabled-label` : ''; + const disabledClass = inputProps?.disabled ? `${baseClassName}-disabled-label` : ''; return (
( variant={TextVariants.body2} className={`${baseClassName}__label ${disabledClass}`} element={(props) => ( -
); diff --git a/src/components/Filter/_filter.scss b/src/components/Filter/_filter.scss index 6e94bb95..89b14ff2 100644 --- a/src/components/Filter/_filter.scss +++ b/src/components/Filter/_filter.scss @@ -40,6 +40,11 @@ $chevron-scale: 0.8; transform: scale($chevron-scale); } + &__fieldset { + border: none; + padding: 0; + } + &.#{$px}-has-separators { border-bottom: 1px solid $light-gray; margin-bottom: $spacing-md; diff --git a/src/patterns/FilterControl/FilterControl.stories.tsx b/src/patterns/FilterControl/FilterControl.stories.tsx index 09c68822..7cc8c22f 100644 --- a/src/patterns/FilterControl/FilterControl.stories.tsx +++ b/src/patterns/FilterControl/FilterControl.stories.tsx @@ -180,8 +180,8 @@ const LotsWithFilter = (props: PropTypes) => { setResults(filterResults); }; - const handleFilter = (e: React.ChangeEvent, filterId: string) => { - const { name, checked } = e.target; + const handleFilter = (e: React.ChangeEvent, filterId: string) => { + const { name, checked } = e.target as HTMLInputElement; const newFilterRules = updateFilterRules(filterId, checked, name); updateFilters(filterId, checked, name); @@ -206,19 +206,22 @@ const LotsWithFilter = (props: PropTypes) => { ))} - + {filters.map((filter: FilterType, index: number) => ( {Array.from(filter.filterDimensions).map((value: FilterDimension) => ( handleFilter(e, filter.id)} inputType={filter.inputType} - disabled={value?.disabled} - name={value.label} - isActive={value.active} + inputProps={{ + onChange: (e) => handleFilter(e, filter.id), + id: value.label, + labelText: value.label, + disabled: value?.disabled, + name: value.label, + checked: value.active, + }} /> ))} diff --git a/src/patterns/FilterControl/FilterControl.test.tsx b/src/patterns/FilterControl/FilterControl.test.tsx index 3ebedaca..033681a5 100644 --- a/src/patterns/FilterControl/FilterControl.test.tsx +++ b/src/patterns/FilterControl/FilterControl.test.tsx @@ -13,21 +13,23 @@ describe('FilterControl', () => { runCommonTests(FilterControl, 'FilterControl'); const handleChange = vi.fn(); const filters = Array(11).fill(0); - const mockAction = '/dummy'; it('should render the "View All" button when there are more than 10 filters', () => { render( - + {filters.map((_, i) => ( ))} @@ -39,17 +41,20 @@ describe('FilterControl', () => { it('should hide values over viewAllLimit and display those under', () => { render( - + {filters.map((_, i) => ( ))} @@ -63,28 +68,34 @@ describe('FilterControl', () => { it('render the child filter', () => { render( - + {filters.map((_, i) => ( ))} , @@ -104,17 +115,20 @@ describe('FilterControl', () => { it('should handle the back button click', () => { render( - + {filters.map((_, i) => ( ))} @@ -132,23 +146,29 @@ describe('FilterControl', () => { it('renders multiple filters and has separators', () => { render( - + , diff --git a/src/patterns/FilterControl/FilterControl.tsx b/src/patterns/FilterControl/FilterControl.tsx index 405f0148..a3a7a998 100644 --- a/src/patterns/FilterControl/FilterControl.tsx +++ b/src/patterns/FilterControl/FilterControl.tsx @@ -1,13 +1,14 @@ -import React, { ComponentProps, forwardRef, useState, Children, cloneElement } from 'react'; +import React, { forwardRef, useState, Children, cloneElement } from 'react'; import { getCommonProps } from '../../utils'; import classnames from 'classnames'; import { FilterComponent, FilterProps } from '../../components/Filter/Filter'; -export interface FilterControlProps extends ComponentProps<'div'> { +export interface FilterControlProps extends React.HTMLAttributes { // This is a composable component that is expecting a Filter component or an array of children: FilterComponent | FilterComponent[]; - action: string; + //Optional element to render as the top-level component e.g. 'div', Form, CustomComponent, etc. Defaults to 'form'. + element?: React.ElementType; } /** * ## Overview @@ -19,7 +20,7 @@ export interface FilterControlProps extends ComponentProps<'div'> { * [Storybook Link](https://phillips-seldon.netlify.app/?path=/docs/patterns-filtercontrol--overview) */ const FilterControl = forwardRef( - ({ className, children, action, ...props }, ref) => { + ({ className, children, element: Element = 'form', ...props }, ref) => { const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterControl'); // this state variable will be set to the filter name when view all has been clicked, @@ -40,7 +41,9 @@ const FilterControl = forwardRef( return (
-
{parsedChildren}
+ + {parsedChildren} +
); }, From ab7b24c97583cb0c7eec0b2065327f9cfa2845c4 Mon Sep 17 00:00:00 2001 From: Zachary Rose Date: Tue, 19 Nov 2024 08:09:26 -0500 Subject: [PATCH 04/11] feat(filter): L3-4114 test tweak --- src/patterns/FilterControl/FilterControl.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/patterns/FilterControl/FilterControl.test.tsx b/src/patterns/FilterControl/FilterControl.test.tsx index 033681a5..20372524 100644 --- a/src/patterns/FilterControl/FilterControl.test.tsx +++ b/src/patterns/FilterControl/FilterControl.test.tsx @@ -140,7 +140,7 @@ describe('FilterControl', () => { //wait for transition to be done setTimeout(() => { - expect(screen.getByText(VIEW_ALL_BUTTON_TEXT)).toBeInTheDocument(); + expect(screen.getAllByText(VIEW_ALL_BUTTON_TEXT)).toBeInTheDocument(); }, 500); }); From 011799dad8a085e5ef3678acb8029e5307990198 Mon Sep 17 00:00:00 2001 From: Zachary Rose Date: Tue, 19 Nov 2024 08:16:48 -0500 Subject: [PATCH 05/11] feat(filter): L3-4114 test tweak --- src/patterns/FilterControl/FilterControl.test.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/patterns/FilterControl/FilterControl.test.tsx b/src/patterns/FilterControl/FilterControl.test.tsx index 20372524..b46b57d2 100644 --- a/src/patterns/FilterControl/FilterControl.test.tsx +++ b/src/patterns/FilterControl/FilterControl.test.tsx @@ -113,7 +113,7 @@ describe('FilterControl', () => { expect(filter2Value).toHaveClass(`${px}-filter--hidden`); }); - it('should handle the back button click', () => { + it('should handle the back button click', async () => { render( @@ -138,10 +138,8 @@ describe('FilterControl', () => { fireEvent.click(screen.getByText(VIEW_ALL_BUTTON_TEXT)); fireEvent.click(screen.getByText(BACK_BUTTON_TEXT)); - //wait for transition to be done - setTimeout(() => { - expect(screen.getAllByText(VIEW_ALL_BUTTON_TEXT)).toBeInTheDocument(); - }, 500); + const button = await screen.findByText(VIEW_ALL_BUTTON_TEXT); + expect(button).toBeInTheDocument(); }); it('renders multiple filters and has separators', () => { From bf98d833e3fa088ca888104810520947e610351a Mon Sep 17 00:00:00 2001 From: Scott Dickerson <6663002+scottdickerson@users.noreply.github.com> Date: Wed, 20 Nov 2024 15:55:42 -0600 Subject: [PATCH 06/11] refactor(FilterValue): rename and fix types --- package-lock.json | 81 +++++++------ package.json | 1 + src/components/DatePicker/DatePicker.tsx | 6 +- .../ErrorBoundary/ErrorBoundary.test.tsx | 2 +- src/components/Filter/Filter.stories.tsx | 43 ++++--- src/components/Filter/Filter.test.tsx | 54 ++++----- src/components/Filter/Filter.tsx | 11 +- src/components/Filter/FilterInput.tsx | 38 ++++++ src/components/Filter/FilterValue.tsx | 76 ------------ src/components/Filter/_filter.scss | 49 +++----- src/components/Filter/index.ts | 2 + src/components/Input/Input.stories.tsx | 14 +-- src/components/Input/Input.test.tsx | 2 +- src/components/Input/Input.tsx | 29 +++-- src/components/Input/_input.scss | 2 +- src/components/Pagination/Pagination.tsx | 3 +- src/components/Select/Select.stories.tsx | 2 +- src/components/Select/Select.tsx | 27 +++-- src/components/Toggle/Toggle.stories.tsx | 2 +- .../FilterControl/FilterControl.stories.tsx | 30 +++-- .../FilterControl/FilterControl.test.tsx | 114 ++++++++---------- src/patterns/FilterControl/FilterControl.tsx | 14 +-- .../FilterControl/_filterControl.scss | 4 +- .../ViewingsList/ViewingsListCard.tsx | 2 +- .../ViewingsList/ViewingsListCardForm.tsx | 36 +++--- tsconfig.json | 1 + 26 files changed, 298 insertions(+), 347 deletions(-) create mode 100644 src/components/Filter/FilterInput.tsx delete mode 100644 src/components/Filter/FilterValue.tsx diff --git a/package-lock.json b/package-lock.json index 25ad03fa..6d5cb85c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,7 @@ "stylelint-order": "^6.0.4", "stylelint-scss": "^6.4.1", "ts-node": "^10.9.2", + "type-fest": "^4.27.0", "typescript": "^5.5.4", "vite": "^5.4.6", "vite-plugin-dts": "^4.2.4", @@ -3553,6 +3554,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/js": { "version": "8.57.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", @@ -7130,18 +7143,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/npm/node_modules/type-fest": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.18.2.tgz", - "integrity": "sha512-+suCYpfJLAe4OXS6+PPXjW3urOS4IoP9waSiLuXfLgqZODKw/aWwASvzqE886wA0kQgGy0mIWyhd87VpqIy6Xg==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/npm/node_modules/unique-string": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", @@ -7283,18 +7284,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/type-fest": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.18.2.tgz", - "integrity": "sha512-+suCYpfJLAe4OXS6+PPXjW3urOS4IoP9waSiLuXfLgqZODKw/aWwASvzqE886wA0kQgGy0mIWyhd87VpqIy6Xg==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -11561,6 +11550,18 @@ "node": ">=8" } }, + "node_modules/boxen/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -14278,6 +14279,18 @@ "node": ">=8" } }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -25754,18 +25767,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-package-up/node_modules/type-fest": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.18.2.tgz", - "integrity": "sha512-+suCYpfJLAe4OXS6+PPXjW3urOS4IoP9waSiLuXfLgqZODKw/aWwASvzqE886wA0kQgGy0mIWyhd87VpqIy6Xg==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -28822,12 +28823,12 @@ } }, "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.27.0.tgz", + "integrity": "sha512-3IMSWgP7C5KSQqmo1wjhKrwsvXAtF33jO3QY+Uy++ia7hqvgSK6iXbbg5PbDBc1P2ZbNEDgejOrN4YooXvhwCw==", "dev": true, "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index d1fa1a97..758eb3b0 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "stylelint-order": "^6.0.4", "stylelint-scss": "^6.4.1", "ts-node": "^10.9.2", + "type-fest": "^4.27.0", "typescript": "^5.5.4", "vite": "^5.4.6", "vite-plugin-dts": "^4.2.4", diff --git a/src/components/DatePicker/DatePicker.tsx b/src/components/DatePicker/DatePicker.tsx index 3e7920bf..7a652078 100644 --- a/src/components/DatePicker/DatePicker.tsx +++ b/src/components/DatePicker/DatePicker.tsx @@ -6,7 +6,7 @@ import classnames from 'classnames'; import { noOp, useNormalizedInputProps } from '../../utils'; import Input, { InputProps } from '../Input/Input'; -export interface DatePickerProps extends Omit, Record { +export interface DatePickerProps extends Omit, Record { /** * Optionally allow manual entry to the date input */ @@ -181,12 +181,12 @@ const DatePicker = React.forwardRef( }; }, [allowInput, defaultValue, inputProps.disabled, enableTime, id, locale, onChange, ref, type]); - const handleKeyPress = (e: KeyboardEvent) => { + const handleKeyPress: React.KeyboardEventHandler = (e) => { if (allowInput && /[0-9-/:]+/g.test(e.key)) { manualValue.current = fp?.current?.selectedDates; } }; - const handOnBlur = (e: FocusEvent) => { + const handOnBlur: React.FocusEventHandler = (e) => { if ( !allowInput || (e.relatedTarget instanceof HTMLElement && e.relatedTarget?.classList.value.includes('flatpickr')) diff --git a/src/components/ErrorBoundary/ErrorBoundary.test.tsx b/src/components/ErrorBoundary/ErrorBoundary.test.tsx index 2c1af861..d2df7d37 100644 --- a/src/components/ErrorBoundary/ErrorBoundary.test.tsx +++ b/src/components/ErrorBoundary/ErrorBoundary.test.tsx @@ -13,7 +13,7 @@ const VolatileComponent = ({ throwError }: WrappedProps) => { throwError(); } }, [throwError]); - return ; + return ; }; describe('An ErrorBoundary', () => { diff --git a/src/components/Filter/Filter.stories.tsx b/src/components/Filter/Filter.stories.tsx index 68fff9c9..8073e03f 100644 --- a/src/components/Filter/Filter.stories.tsx +++ b/src/components/Filter/Filter.stories.tsx @@ -1,7 +1,7 @@ import { Meta } from '@storybook/react'; import Filter from './Filter'; import FilterHeader from './FilterHeader'; -import FilterValue from './FilterValue'; +import FilterInput from './FilterInput'; import { useState } from 'react'; // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction @@ -12,7 +12,7 @@ const meta = { export default meta; -type FilterDimension = { label: string; disabled?: boolean | undefined; active: boolean }; +type FilterDimension = { label: string; disabled?: boolean | undefined; checked: boolean }; type FilterType = { label: string; @@ -31,15 +31,15 @@ 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 }, + { label: 'Jimmy', checked: true }, + { label: 'Bob', checked: false }, + { label: 'Alan', checked: false }, + { label: 'Nick', checked: false }, + { label: 'Joe', checked: false }, + { label: 'Fred', checked: false }, + { label: 'Rob', checked: false }, + { label: 'Roy', checked: false }, + { label: 'disabled', disabled: true, checked: false }, ], }; @@ -53,7 +53,7 @@ const FilterComponent = (props: PropTypes) => { if (dimension.label === name) { return { ...dimension, - active: checked, + checked, }; } return dimension; @@ -66,6 +66,7 @@ const FilterComponent = (props: PropTypes) => { setFilter(updatedFilter); }; + return ( { > {filter.filterDimensions.map((value: FilterDimension) => ( - ))} diff --git a/src/components/Filter/Filter.test.tsx b/src/components/Filter/Filter.test.tsx index f5d83fcb..413ecc46 100644 --- a/src/components/Filter/Filter.test.tsx +++ b/src/components/Filter/Filter.test.tsx @@ -2,8 +2,7 @@ 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'; +import FilterInput from './FilterInput'; describe('Filter', () => { runCommonTests(Filter, 'Filter'); @@ -13,25 +12,21 @@ describe('Filter', () => { render( - - , ); @@ -61,22 +56,19 @@ describe('Filter', () => { render( - , ); const checkbox = screen.getByRole('checkbox'); - expect(screen.getByTestId('text')).toHaveClass(`${px}-filter-value-disabled-label`); expect(checkbox).toBeDisabled(); }); }); diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx index d65f6608..4cb8b653 100644 --- a/src/components/Filter/Filter.tsx +++ b/src/components/Filter/Filter.tsx @@ -12,7 +12,7 @@ import { import { getCommonProps, px } from '../../utils'; import classnames from 'classnames'; import FilterHeader, { FilterHeaderProps } from './FilterHeader'; -import { FilterValueProps } from './FilterValue'; +import { FilterInputProps } from './FilterInput'; import Button from '../Button/Button'; import { ButtonVariants } from '../Button/types'; import ChevronNextIcon from '../../assets/chevronNext.svg?react'; @@ -37,6 +37,10 @@ export interface FilterProps extends ComponentProps<'div'> { // Whether this filter is being hidden. Use when sibling is in view all state. isHidden?: boolean; + /** + * translatable string for view all button + */ + viewAllLabel?: string; } /** * ## Overview @@ -58,6 +62,7 @@ const Filter = forwardRef( isViewingAll = false, isHidden = false, setViewAllFilter, + viewAllLabel = 'View All', ...props }, ref, @@ -76,7 +81,7 @@ const Filter = forwardRef( isValidElement(child) ? child.type === FilterHeader ? cloneElement(child, headerProps as FilterHeaderProps) - : cloneElement(child, { isHidden: !isViewingAll && index > viewAllLimit } as FilterValueProps) + : cloneElement(child, { hidden: !isViewingAll && index > viewAllLimit } as FilterInputProps) : child, ); @@ -101,7 +106,7 @@ const Filter = forwardRef( setViewAllFilter?.(name); }} > - View All + {viewAllLabel} ) : null} diff --git a/src/components/Filter/FilterInput.tsx b/src/components/Filter/FilterInput.tsx new file mode 100644 index 00000000..e12b8aaf --- /dev/null +++ b/src/components/Filter/FilterInput.tsx @@ -0,0 +1,38 @@ +import { forwardRef } from 'react'; +import { getCommonProps } from '../../utils'; +import Input, { InputProps } from '../Input/Input'; + +export interface FilterInputProps extends InputProps { + /** + * Type of input for this filter only radios or checkboxes allowed + */ + type: 'checkbox' | 'radio'; +} +/** + * ## Overview + * + * A label & input for filtering criteria + * + */ +const FilterInput = forwardRef( + ({ className, type = 'checkbox', name, hidden, onChange, ...props }, ref) => { + const { className: baseClassName } = getCommonProps(props, 'FilterInput'); + return ( + + ); + }, +); + +FilterInput.displayName = 'FilterValue'; + +export default FilterInput; diff --git a/src/components/Filter/FilterValue.tsx b/src/components/Filter/FilterValue.tsx deleted file mode 100644 index 11b6b58d..00000000 --- a/src/components/Filter/FilterValue.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { ComponentProps, forwardRef } from 'react'; -import { getCommonProps, px } from '../../utils'; -import classnames from 'classnames'; -import { Text, TextVariants } from '../Text'; -import Input, { InputProps } 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, 'onChange' | 'value'> { - // 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; - - inputProps: InputProps; -} -/** - * ## Overview - * - * A label & input for filtering criteria - * - */ -const FilterValue = forwardRef( - ( - { - className, - // name, - // label, - inputType = 'checkbox', - isHidden = false, - // onChange, - // disabled, - // isActive, - inputProps, - ...props - }, - ref, - ) => { - const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterValue'); - const disabledClass = inputProps?.disabled ? `${baseClassName}-disabled-label` : ''; - return ( -
- ( - - )} - /> - -
- ); - }, -); - -FilterValue.displayName = 'FilterValue'; - -export default FilterValue; diff --git a/src/components/Filter/_filter.scss b/src/components/Filter/_filter.scss index 89b14ff2..b0699c0b 100644 --- a/src/components/Filter/_filter.scss +++ b/src/components/Filter/_filter.scss @@ -41,20 +41,29 @@ $chevron-scale: 0.8; } &__fieldset { - border: none; + border: 0; + margin: 0 3px 0 0; // save room for input borders padding: 0; } + .#{$px}-input { + &__label { + @include text($body2); + + color: inherit; + } + + &__validation { + display: none; + } + } + &.#{$px}-has-separators { border-bottom: 1px solid $light-gray; margin-bottom: $spacing-md; padding-bottom: $spacing-sm; } - &--hidden { - @include hidden; - } - &.is-opening { animation: slide-in $default-transition-duration $default-easing forwards; } @@ -64,35 +73,13 @@ $chevron-scale: 0.8; } } -.#{$px}-filter-value { - display: flex; - flex-direction: row; - height: 40px; - justify-content: space-between; - - &__label { - font-variation-settings: 'wght' 600; - width: 70%; - - &:hover { - cursor: pointer; - } - } - - &-disabled-label { - color: $keyline-gray; - cursor: default !important; - } - - &--hidden { - @include hidden; - } - +.#{$px}-filter-input { &__input { &__wrapper { + display: flex; flex-direction: row; - justify-content: flex-end; - width: 25%; + height: 40px; + justify-content: space-between; } } } diff --git a/src/components/Filter/index.ts b/src/components/Filter/index.ts index 2d75d126..f2fd90bd 100644 --- a/src/components/Filter/index.ts +++ b/src/components/Filter/index.ts @@ -1 +1,3 @@ export { default as Filter, type FilterProps } from './Filter'; +export { default as FilterInput, type FilterInputProps as FilterValueProps } from './FilterInput'; +export { default as FilterHeader, type FilterHeaderProps } from './FilterHeader'; diff --git a/src/components/Input/Input.stories.tsx b/src/components/Input/Input.stories.tsx index 6d49b30e..499ffd41 100644 --- a/src/components/Input/Input.stories.tsx +++ b/src/components/Input/Input.stories.tsx @@ -88,7 +88,7 @@ const argTypes = { export const DateTimeInput = ({ playgroundWidth, ...args }: StoryProps) => (
- +
); @@ -109,14 +109,14 @@ DateTimeInput.args = { export const RadioInput = ({ playgroundWidth, ...args }: StoryProps) => (
- +
); @@ -136,8 +136,8 @@ RadioInput.args = { export const CheckboxInput = ({ playgroundWidth, ...args }: StoryProps) => (
- - + +
); @@ -159,7 +159,7 @@ CheckboxInput.argTypes = argTypes; export const RangeInput = ({ playgroundWidth, ...args }: StoryProps) => (
- +
); @@ -185,7 +185,7 @@ RangeInput.argTypes = argTypes; export const Playground = ({ playgroundWidth, ...args }: StoryProps) => (
- +
); diff --git a/src/components/Input/Input.test.tsx b/src/components/Input/Input.test.tsx index d9834339..64b88d17 100644 --- a/src/components/Input/Input.test.tsx +++ b/src/components/Input/Input.test.tsx @@ -7,7 +7,7 @@ import Input, { InputProps } from './Input'; // NOTE: When using default value on controlled input we pass to the state hook and not the input const TestInput = React.forwardRef( ({ defaultValue, ...props }: InputProps, ref: React.ForwardedRef) => { - const [value, setValue] = React.useState((defaultValue as string) ?? ''); + const [value, setValue] = React.useState((defaultValue as string) ?? ''); const handleOnChange = function (e: React.ChangeEvent) { setValue(e?.currentTarget?.value); }; diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index 4674d6af..dcc062fc 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -3,7 +3,7 @@ import classnames from 'classnames'; import { px, useNormalizedInputProps } from '../../utils'; -export interface InputProps extends Record { +export interface InputProps extends Omit, 'size'> { /** * Optional className to be applied to the `` node */ @@ -12,7 +12,7 @@ export interface InputProps extends Record { /** * Optionally provide the default value of the ``. Should not be passed into controlled input! */ - defaultValue?: string | number; + defaultValue?: string | number | readonly string[]; /** * Booolean to specify whether the `` should be disabled @@ -27,7 +27,7 @@ export interface InputProps extends Record { /** * A custom `id` for the `` */ - id: string; + id?: string; /** * Boolean to dictate whether input is inline with the label or not. `true` to use the inline version. @@ -52,7 +52,7 @@ export interface InputProps extends Record { /** * Optional `onChange` handler that is called whenever `` is updated */ - onChange?: (event: React.ChangeEvent | React.ChangeEvent) => void; + onChange?: (event: React.ChangeEvent) => void; /** * Optional `onClick` handler that is called whenever the `` is clicked @@ -82,7 +82,7 @@ export interface InputProps extends Record { /** * Specify the value of the `` for controlled inputs */ - value?: string | number | undefined; + value?: string | number | undefined | readonly string[]; /** * Boolean to specify whether the control is currently in warning state @@ -134,7 +134,17 @@ const Input = React.forwardRef( }: InputProps, ref: React.ForwardedRef, ) => { - const inputProps = useNormalizedInputProps({ disabled, id, invalid, invalidText, readOnly, type, warn, warnText }); + const generatedId = React.useId(); + const inputProps = useNormalizedInputProps({ + disabled, + id: id || generatedId, + invalid, + invalidText, + readOnly, + type, + warn, + warnText, + }); const wrapperClassnames = classnames(`${px}-${type}-input`, `${px}-input`, `${px}-input--${size}`, { [`${px}-input--inline`]: inline, @@ -147,7 +157,10 @@ const Input = React.forwardRef( }); return (
-
); diff --git a/src/patterns/FilterControl/FilterControl.stories.tsx b/src/patterns/FilterControl/FilterControl.stories.tsx index 7cc8c22f..ece02d13 100644 --- a/src/patterns/FilterControl/FilterControl.stories.tsx +++ b/src/patterns/FilterControl/FilterControl.stories.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'; import Button from '../../components/Button/Button'; import { Drawer } from '../../components/Drawer'; import FilterHeader from '../../components/Filter/FilterHeader'; -import FilterValue from '../../components/Filter/FilterValue'; +import FilterInput from '../../components/Filter/FilterInput'; import { Filter } from '../../components/Filter'; import { exampleAuctionLots, lotType } from './utils'; @@ -27,7 +27,7 @@ type FilterType = { label: string; id: string; filterDimensions: FilterDimension[]; - inputType: 'checkbox' | 'radio'; + type: 'checkbox' | 'radio'; }; type PropTypes = { @@ -44,9 +44,9 @@ const FILTER_KEYS = { const filters: FilterType[] = [ { - label: 'SortBy', + label: 'Sort By', id: FILTER_KEYS.sortBy, - inputType: 'radio', + type: 'radio', filterDimensions: [ { label: 'Lot Number', active: true }, { label: 'A-Z Artist Maker', active: false }, @@ -57,13 +57,13 @@ const filters: FilterType[] = [ { label: 'Artists & Makers', id: FILTER_KEYS.makers, - inputType: 'checkbox', + type: 'checkbox', filterDimensions: [], }, { label: 'Collections', id: FILTER_KEYS.collections, - inputType: 'checkbox', + type: 'checkbox', filterDimensions: [], }, ]; @@ -211,17 +211,15 @@ const LotsWithFilter = (props: PropTypes) => { {Array.from(filter.filterDimensions).map((value: FilterDimension) => ( - handleFilter(e, filter.id), - id: value.label, - labelText: value.label, - disabled: value?.disabled, - name: value.label, - checked: value.active, - }} + labelText={value.label} + onChange={(e) => handleFilter(e, filter.id)} + type={filter.type} + disabled={value?.disabled} + name={value.label} + checked={value.active} /> ))} diff --git a/src/patterns/FilterControl/FilterControl.test.tsx b/src/patterns/FilterControl/FilterControl.test.tsx index b46b57d2..ccc9e87f 100644 --- a/src/patterns/FilterControl/FilterControl.test.tsx +++ b/src/patterns/FilterControl/FilterControl.test.tsx @@ -2,7 +2,7 @@ import FilterControl from './FilterControl'; import { runCommonTests } from '../../utils/testUtils'; import { render, screen, fireEvent } from '@testing-library/react'; import FilterHeader from '../../components/Filter/FilterHeader'; -import FilterValue from '../../components/Filter/FilterValue'; +import FilterInput from '../../components/Filter/FilterInput'; import { Filter } from '../../components/Filter'; import { px } from '../../utils'; @@ -20,16 +20,14 @@ describe('FilterControl', () => { {filters.map((_, i) => ( - ))} @@ -45,16 +43,14 @@ describe('FilterControl', () => { {filters.map((_, i) => ( - ))} @@ -72,30 +68,26 @@ describe('FilterControl', () => { {filters.map((_, i) => ( - ))} -
, @@ -119,16 +111,14 @@ describe('FilterControl', () => { {filters.map((_, i) => ( - ))} @@ -146,27 +136,23 @@ describe('FilterControl', () => { render( - - , diff --git a/src/patterns/FilterControl/FilterControl.tsx b/src/patterns/FilterControl/FilterControl.tsx index a3a7a998..f6d9109f 100644 --- a/src/patterns/FilterControl/FilterControl.tsx +++ b/src/patterns/FilterControl/FilterControl.tsx @@ -3,7 +3,7 @@ import { getCommonProps } from '../../utils'; import classnames from 'classnames'; import { FilterComponent, FilterProps } from '../../components/Filter/Filter'; -export interface FilterControlProps extends React.HTMLAttributes { +export interface FilterControlProps extends React.HTMLAttributes { // This is a composable component that is expecting a Filter component or an array of children: FilterComponent | FilterComponent[]; @@ -19,7 +19,7 @@ export interface FilterControlProps extends React.HTM * * [Storybook Link](https://phillips-seldon.netlify.app/?path=/docs/patterns-filtercontrol--overview) */ -const FilterControl = forwardRef( +const FilterControl = forwardRef( ({ className, children, element: Element = 'form', ...props }, ref) => { const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterControl'); @@ -32,7 +32,7 @@ const FilterControl = forwardRef( React.isValidElement(childElement) ? cloneElement(childElement, { setViewAllFilter, - isHidden: !isViewAllSet ? false : viewAllFilter !== childElement.props.name, + hidden: !isViewAllSet ? false : viewAllFilter !== childElement.props.name, isViewingAll: isViewAllSet, className: isViewAllSet && classnames(childElement.props.className, 'is-opening'), } as FilterProps) @@ -40,11 +40,9 @@ const FilterControl = forwardRef( ); return ( -
- - {parsedChildren} - -
+ + {parsedChildren} + ); }, ); diff --git a/src/patterns/FilterControl/_filterControl.scss b/src/patterns/FilterControl/_filterControl.scss index 32a7dce4..f7e779fa 100644 --- a/src/patterns/FilterControl/_filterControl.scss +++ b/src/patterns/FilterControl/_filterControl.scss @@ -1,8 +1,8 @@ @use '#scss/allPartials' as *; .#{$px}-filter-control { - overflow: scroll; - width: 90%; + overflow-y: scroll; + width: 100%; &::-webkit-scrollbar { display: none; diff --git a/src/patterns/ViewingsList/ViewingsListCard.tsx b/src/patterns/ViewingsList/ViewingsListCard.tsx index 5458a5f1..a633ca61 100644 --- a/src/patterns/ViewingsList/ViewingsListCard.tsx +++ b/src/patterns/ViewingsList/ViewingsListCard.tsx @@ -214,7 +214,7 @@ const ViewingsListCard = ({ labelText={locationLabel} size="sm" name="location" - invalid={invalidFields?.location} + invalid={!!invalidFields?.location} invalidText={invalidFields?.location} readOnly={!editState} /> diff --git a/src/patterns/ViewingsList/ViewingsListCardForm.tsx b/src/patterns/ViewingsList/ViewingsListCardForm.tsx index 3d4e9380..684f287d 100644 --- a/src/patterns/ViewingsList/ViewingsListCardForm.tsx +++ b/src/patterns/ViewingsList/ViewingsListCardForm.tsx @@ -213,7 +213,7 @@ const ViewingsListCardForm = ({ defaultValue={viewingLabelValue} labelText={viewingLabel} size="sm" - invalid={invalidFields?.viewingLabelValue} + invalid={!!invalidFields?.viewingLabelValue} invalidText={invalidFields?.viewingLabelValue} /> @@ -251,9 +251,9 @@ const ViewingsListCardForm = ({ size="md" defaultChecked={previewOnState} inline - invalid={invalidFields?.previewOn} + invalid={!!invalidFields?.previewOn} invalidText={invalidFields?.previewOn} - value={true} + checked={true} name="previewOn" onChange={() => setPreviewOnState((oldState) => !oldState)} /> @@ -268,7 +268,7 @@ const ViewingsListCardForm = ({ defaultValue={previewLabelValue} labelText={previewLabel} size="sm" - invalid={invalidFields?.previewLabelValue} + invalid={!!invalidFields?.previewLabelValue} invalidText={invalidFields?.previewLabelValue} hidden={!previewOnState} /> @@ -278,7 +278,7 @@ const ViewingsListCardForm = ({ defaultValue={previewDates} labelText={previewDatesLabel} size="sm" - invalid={invalidFields?.previewDates} + invalid={!!invalidFields?.previewDates} invalidText={invalidFields?.previewDates} hidden={!previewOnState} /> @@ -288,7 +288,7 @@ const ViewingsListCardForm = ({ defaultValue={previewHours1} labelText={previewHours1Label} size="sm" - invalid={invalidFields?.previewHours1} + invalid={!!invalidFields?.previewHours1} invalidText={invalidFields?.previewHours1} hidden={!previewOnState} /> @@ -298,7 +298,7 @@ const ViewingsListCardForm = ({ defaultValue={previewHours2} labelText={previewHours2Label} size="sm" - invalid={invalidFields?.previewHours2} + invalid={!!invalidFields?.previewHours2} invalidText={invalidFields?.previewHours2} hidden={!previewOnState} /> @@ -310,7 +310,7 @@ const ViewingsListCardForm = ({ defaultValue={address1} labelText={address1Label} size="sm" - invalid={invalidFields?.address1} + invalid={!!invalidFields?.address1} invalidText={invalidFields?.address1} /> @@ -329,7 +329,7 @@ const ViewingsListCardForm = ({ defaultValue={address3} labelText={address3Label} size="sm" - invalid={invalidFields?.address3} + invalid={!!invalidFields?.address3} invalidText={invalidFields?.address3} /> @@ -340,7 +340,7 @@ const ViewingsListCardForm = ({ labelText={addressUrlLabel} size="sm" type="url" - invalid={invalidFields?.addressUrl} + invalid={!!invalidFields?.addressUrl} invalidText={invalidFields?.addressUrl} /> @@ -351,9 +351,9 @@ const ViewingsListCardForm = ({ size="md" defaultChecked={emailOnState} inline - invalid={invalidFields?.emailOn} + invalid={!!invalidFields?.emailOn} invalidText={invalidFields?.emailOn} - value={true} + checked={true} name="emailOn" onChange={() => setEmailOnState((oldState) => !oldState)} /> @@ -368,7 +368,7 @@ const ViewingsListCardForm = ({ defaultValue={email} labelText={emailLabel} size="sm" - invalid={invalidFields?.address2} + invalid={!!invalidFields?.address2} invalidText={invalidFields?.address2} hidden={!emailOnState} /> @@ -378,7 +378,7 @@ const ViewingsListCardForm = ({ defaultValue={emailLink} labelText={emailLinkLabel} size="sm" - invalid={invalidFields?.address3} + invalid={!!invalidFields?.address3} invalidText={invalidFields?.address3} hidden={!emailOnState} /> diff --git a/tsconfig.json b/tsconfig.json index 4a3eef01..0b308742 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,7 @@ "resolveJsonModule": true, /* Linting */ + "strictNullChecks": true, "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, From 7b36d20493027692c3c28c99670e134f911ece27 Mon Sep 17 00:00:00 2001 From: Scott Dickerson <6663002+scottdickerson@users.noreply.github.com> Date: Thu, 21 Nov 2024 07:40:52 -0600 Subject: [PATCH 07/11] refactor(filtercontrol): rename to filter menu --- src/componentStyles.scss | 2 +- src/index.ts | 2 +- src/patterns/FilterControl/index.ts | 1 - .../FilterMenu.stories.tsx} | 10 ++-- .../FilterMenu.test.tsx} | 50 ++++++++++--------- .../FilterMenu.tsx} | 29 ++++++----- .../_filterMenu.scss} | 2 +- src/patterns/FilterMenu/index.ts | 1 + .../{FilterControl => FilterMenu}/utils.tsx | 0 .../ViewingsList/ViewingsListCardForm.tsx | 4 +- 10 files changed, 54 insertions(+), 47 deletions(-) delete mode 100644 src/patterns/FilterControl/index.ts rename src/patterns/{FilterControl/FilterControl.stories.tsx => FilterMenu/FilterMenu.stories.tsx} (97%) rename src/patterns/{FilterControl/FilterControl.test.tsx => FilterMenu/FilterMenu.test.tsx} (79%) rename src/patterns/{FilterControl/FilterControl.tsx => FilterMenu/FilterMenu.tsx} (60%) rename src/patterns/{FilterControl/_filterControl.scss => FilterMenu/_filterMenu.scss} (82%) create mode 100644 src/patterns/FilterMenu/index.ts rename src/patterns/{FilterControl => FilterMenu}/utils.tsx (100%) diff --git a/src/componentStyles.scss b/src/componentStyles.scss index ff3c8ac4..b69fbe29 100644 --- a/src/componentStyles.scss +++ b/src/componentStyles.scss @@ -54,5 +54,5 @@ @use 'components/PinchZoom/pinchZoom'; @use 'components/Tabs/tabs'; @use 'components/SeldonImage/seldonImage'; -@use 'patterns/FilterControl/filterControl'; +@use 'patterns/FilterMenu/filterMenu'; @use 'components/Filter/filter'; diff --git a/src/index.ts b/src/index.ts index 153a81fc..34061a84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,5 +82,5 @@ export * from './patterns/SaleHeaderBanner'; // utils export * from './utils/hooks'; -export * from './patterns/FilterControl'; +export * from './patterns/FilterMenu'; export * from './components/Filter'; diff --git a/src/patterns/FilterControl/index.ts b/src/patterns/FilterControl/index.ts deleted file mode 100644 index cb562be9..00000000 --- a/src/patterns/FilterControl/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as FilterControl, type FilterControlProps } from './FilterControl'; diff --git a/src/patterns/FilterControl/FilterControl.stories.tsx b/src/patterns/FilterMenu/FilterMenu.stories.tsx similarity index 97% rename from src/patterns/FilterControl/FilterControl.stories.tsx rename to src/patterns/FilterMenu/FilterMenu.stories.tsx index ece02d13..3c218b4c 100644 --- a/src/patterns/FilterControl/FilterControl.stories.tsx +++ b/src/patterns/FilterMenu/FilterMenu.stories.tsx @@ -1,5 +1,5 @@ import { Meta } from '@storybook/react'; -import FilterControl from './FilterControl'; +import FilterMenu from './FilterMenu'; import { useArgs } from '@storybook/preview-api'; import { useEffect, useRef, useState } from 'react'; import Button from '../../components/Button/Button'; @@ -12,8 +12,8 @@ import { exampleAuctionLots, lotType } from './utils'; // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction const meta = { title: 'Patterns/FilterControl', - component: FilterControl, -} satisfies Meta; + component: FilterMenu, +} satisfies Meta; export default meta; @@ -206,7 +206,7 @@ const LotsWithFilter = (props: PropTypes) => { ))} - + {filters.map((filter: FilterType, index: number) => ( @@ -224,7 +224,7 @@ const LotsWithFilter = (props: PropTypes) => { ))} ))} - + diff --git a/src/patterns/FilterControl/FilterControl.test.tsx b/src/patterns/FilterMenu/FilterMenu.test.tsx similarity index 79% rename from src/patterns/FilterControl/FilterControl.test.tsx rename to src/patterns/FilterMenu/FilterMenu.test.tsx index ccc9e87f..edee838a 100644 --- a/src/patterns/FilterControl/FilterControl.test.tsx +++ b/src/patterns/FilterMenu/FilterMenu.test.tsx @@ -1,4 +1,4 @@ -import FilterControl from './FilterControl'; +import FilterMenu from './FilterMenu'; import { runCommonTests } from '../../utils/testUtils'; import { render, screen, fireEvent } from '@testing-library/react'; import FilterHeader from '../../components/Filter/FilterHeader'; @@ -9,14 +9,14 @@ import { px } from '../../utils'; const BACK_BUTTON_TEXT = 'Back to all'; const VIEW_ALL_BUTTON_TEXT = 'View All'; -describe('FilterControl', () => { - runCommonTests(FilterControl, 'FilterControl'); +describe('FilterMenu', () => { + runCommonTests(FilterMenu, 'FilterMenu'); const handleChange = vi.fn(); const filters = Array(11).fill(0); it('should render the "View All" button when there are more than 10 filters', () => { render( - + {filters.map((_, i) => ( @@ -31,7 +31,7 @@ describe('FilterControl', () => { /> ))} - , + , ); expect(screen.getByTestId('button')).toBeInTheDocument(); @@ -39,7 +39,7 @@ describe('FilterControl', () => { it('should hide values over viewAllLimit and display those under', () => { render( - + {filters.map((_, i) => ( @@ -54,46 +54,49 @@ describe('FilterControl', () => { /> ))} - , + , ); - const values = screen.getAllByTestId('filter-value'); - expect(values[2]).toBeVisible(); - expect(values[3]).toHaveClass(`${px}-input__label--hidden`); + const filterCheckboxes = screen.getAllByRole('checkbox', { hidden: true }); + expect(filterCheckboxes[2]).toBeVisible(); + expect(filterCheckboxes[3]).toHaveProperty('hidden', true); }); it('render the child filter', () => { render( - - + + {filters.map((_, i) => ( ))} - + - , + , ); - const filter2Value = screen.getAllByTestId('filter')[1]; + const filterCheckboxes = screen.getAllByRole('checkbox', { hidden: true }); + expect(filterCheckboxes).toHaveLength(11); + expect(filterCheckboxes[0]).toBeVisible(); + expect(filterCheckboxes[10]).toHaveProperty('hidden', true); // View All button fireEvent.click(screen.getByTestId('button')); @@ -101,13 +104,14 @@ describe('FilterControl', () => { // Back button only for first filter should be displayed expect(screen.getAllByText(BACK_BUTTON_TEXT)[0]).toBeVisible(); - // Second filter should no longer be displayed - expect(filter2Value).toHaveClass(`${px}-filter--hidden`); + // Second filter should be hidden + const secondFilter = screen.getByTestId('filter-filter2'); + expect(secondFilter).toHaveProperty('hidden', true); }); it('should handle the back button click', async () => { render( - + {filters.map((_, i) => ( @@ -122,7 +126,7 @@ describe('FilterControl', () => { /> ))} - , + , ); fireEvent.click(screen.getByText(VIEW_ALL_BUTTON_TEXT)); @@ -134,7 +138,7 @@ describe('FilterControl', () => { it('renders multiple filters and has separators', () => { render( - + { checked={true} /> - , + , ); const filters = screen.getAllByTestId('filter'); diff --git a/src/patterns/FilterControl/FilterControl.tsx b/src/patterns/FilterMenu/FilterMenu.tsx similarity index 60% rename from src/patterns/FilterControl/FilterControl.tsx rename to src/patterns/FilterMenu/FilterMenu.tsx index f6d9109f..9cc582cc 100644 --- a/src/patterns/FilterControl/FilterControl.tsx +++ b/src/patterns/FilterMenu/FilterMenu.tsx @@ -1,13 +1,17 @@ -import React, { forwardRef, useState, Children, cloneElement } from 'react'; +import React, { forwardRef, useState, Children, cloneElement, ReactNode } from 'react'; import { getCommonProps } from '../../utils'; import classnames from 'classnames'; -import { FilterComponent, FilterProps } from '../../components/Filter/Filter'; +import Filter, { FilterProps } from '../../components/Filter/Filter'; -export interface FilterControlProps extends React.HTMLAttributes { - // This is a composable component that is expecting a Filter component or an array of - children: FilterComponent | FilterComponent[]; +export interface FilterMenuProps extends React.HTMLAttributes { + /** + * Typically would be a Filter component + * */ + children: ReactNode; - //Optional element to render as the top-level component e.g. 'div', Form, CustomComponent, etc. Defaults to 'form'. + /** + * Optional element to render as the top-level component e.g. 'div', Form, CustomComponent, etc. Defaults to 'form'. + */ element?: React.ElementType; } /** @@ -19,17 +23,16 @@ export interface FilterControlProps extends React * * [Storybook Link](https://phillips-seldon.netlify.app/?path=/docs/patterns-filtercontrol--overview) */ -const FilterControl = forwardRef( +const FilterMenu = forwardRef( ({ className, children, element: Element = 'form', ...props }, ref) => { - const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterControl'); + const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterMenu'); - // this state variable will be set to the filter name when view all has been clicked, - // and null as default and when back is clicked + // The viewAllFilter is the name of the filter that is currently being viewed in the "View All" submenu const [viewAllFilter, setViewAllFilter] = useState(null); const isViewAllSet = viewAllFilter?.length; const parsedChildren = Children.map(children, (childElement) => - React.isValidElement(childElement) + React.isValidElement(childElement) && childElement.type === Filter ? cloneElement(childElement, { setViewAllFilter, hidden: !isViewAllSet ? false : viewAllFilter !== childElement.props.name, @@ -47,6 +50,6 @@ const FilterControl = forwardRef( }, ); -FilterControl.displayName = 'FilterControl'; +FilterMenu.displayName = 'FilterControl'; -export default FilterControl; +export default FilterMenu; diff --git a/src/patterns/FilterControl/_filterControl.scss b/src/patterns/FilterMenu/_filterMenu.scss similarity index 82% rename from src/patterns/FilterControl/_filterControl.scss rename to src/patterns/FilterMenu/_filterMenu.scss index f7e779fa..017a18ed 100644 --- a/src/patterns/FilterControl/_filterControl.scss +++ b/src/patterns/FilterMenu/_filterMenu.scss @@ -1,6 +1,6 @@ @use '#scss/allPartials' as *; -.#{$px}-filter-control { +.#{$px}-filter-menu { overflow-y: scroll; width: 100%; diff --git a/src/patterns/FilterMenu/index.ts b/src/patterns/FilterMenu/index.ts new file mode 100644 index 00000000..d94a12b0 --- /dev/null +++ b/src/patterns/FilterMenu/index.ts @@ -0,0 +1 @@ +export { default as FilterMenu, type FilterMenuProps } from './FilterMenu'; diff --git a/src/patterns/FilterControl/utils.tsx b/src/patterns/FilterMenu/utils.tsx similarity index 100% rename from src/patterns/FilterControl/utils.tsx rename to src/patterns/FilterMenu/utils.tsx diff --git a/src/patterns/ViewingsList/ViewingsListCardForm.tsx b/src/patterns/ViewingsList/ViewingsListCardForm.tsx index 684f287d..db99a8ae 100644 --- a/src/patterns/ViewingsList/ViewingsListCardForm.tsx +++ b/src/patterns/ViewingsList/ViewingsListCardForm.tsx @@ -253,7 +253,7 @@ const ViewingsListCardForm = ({ inline invalid={!!invalidFields?.previewOn} invalidText={invalidFields?.previewOn} - checked={true} + value="true" name="previewOn" onChange={() => setPreviewOnState((oldState) => !oldState)} /> @@ -353,7 +353,7 @@ const ViewingsListCardForm = ({ inline invalid={!!invalidFields?.emailOn} invalidText={invalidFields?.emailOn} - checked={true} + value="true" name="emailOn" onChange={() => setEmailOnState((oldState) => !oldState)} /> From 466c61d3319d0a5b4c528b90f34ce1851de00845 Mon Sep 17 00:00:00 2001 From: Scott Dickerson <6663002+scottdickerson@users.noreply.github.com> Date: Thu, 21 Nov 2024 07:56:12 -0600 Subject: [PATCH 08/11] refactor(filter): rename isLast to hasSeparator to be more consistent --- src/components/Filter/Filter.stories.tsx | 8 ++++---- src/components/Filter/Filter.tsx | 8 ++++---- src/patterns/FilterMenu/FilterMenu.stories.tsx | 2 +- src/patterns/FilterMenu/FilterMenu.test.tsx | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/components/Filter/Filter.stories.tsx b/src/components/Filter/Filter.stories.tsx index 8073e03f..0457f8ad 100644 --- a/src/components/Filter/Filter.stories.tsx +++ b/src/components/Filter/Filter.stories.tsx @@ -23,7 +23,7 @@ type FilterType = { type PropTypes = { filter: FilterType; viewAllLimit: number; - isLast: boolean; + hasSeparator: boolean; isViewingAll: boolean; }; @@ -44,7 +44,7 @@ const filter: FilterType = { }; const FilterComponent = (props: PropTypes) => { - const { filter: intialFilters, viewAllLimit, isLast, isViewingAll } = props; + const { filter: intialFilters, viewAllLimit, hasSeparator, isViewingAll } = props; const [filter, setFilter] = useState(intialFilters); const handleChange = (e: React.ChangeEvent) => { @@ -71,7 +71,7 @@ const FilterComponent = (props: PropTypes) => { @@ -100,7 +100,7 @@ export const Playground = (props: PropTypes) => { Playground.args = { filter, viewAllLimit: 10, - isLast: true, + hasSeparator: false, isViewingAll: false, }; diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx index 4cb8b653..56f1dd6f 100644 --- a/src/components/Filter/Filter.tsx +++ b/src/components/Filter/Filter.tsx @@ -23,8 +23,8 @@ export interface FilterProps extends ComponentProps<'div'> { 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; + // Includes bottom border if true, false by default + hasSeparator?: boolean; // Setter for values to display in view all setViewAllFilter?: Dispatch>; @@ -57,7 +57,7 @@ const Filter = forwardRef( className, children, name, - isLast = true, + hasSeparator = false, viewAllLimit = 10, isViewingAll = false, isHidden = false, @@ -89,7 +89,7 @@ const Filter = forwardRef(
{ {filters.map((filter: FilterType, index: number) => ( - + {Array.from(filter.filterDimensions).map((value: FilterDimension) => ( { it('renders multiple filters and has separators', () => { render( - + { checked={true} /> - + Date: Thu, 21 Nov 2024 08:13:27 -0600 Subject: [PATCH 09/11] fix(filter): remove hasSeparator based on PR feedback --- src/components/Filter/Filter.stories.tsx | 12 ++----- src/components/Filter/Filter.tsx | 12 ++----- src/components/Filter/_filter.scss | 6 ---- .../FilterMenu/FilterMenu.stories.tsx | 4 +-- src/patterns/FilterMenu/FilterMenu.test.tsx | 33 ------------------- src/patterns/FilterMenu/_filterMenu.scss | 5 +++ 6 files changed, 11 insertions(+), 61 deletions(-) diff --git a/src/components/Filter/Filter.stories.tsx b/src/components/Filter/Filter.stories.tsx index 0457f8ad..c1be4423 100644 --- a/src/components/Filter/Filter.stories.tsx +++ b/src/components/Filter/Filter.stories.tsx @@ -23,7 +23,6 @@ type FilterType = { type PropTypes = { filter: FilterType; viewAllLimit: number; - hasSeparator: boolean; isViewingAll: boolean; }; @@ -44,7 +43,7 @@ const filter: FilterType = { }; const FilterComponent = (props: PropTypes) => { - const { filter: intialFilters, viewAllLimit, hasSeparator, isViewingAll } = props; + const { filter: intialFilters, viewAllLimit, isViewingAll } = props; const [filter, setFilter] = useState(intialFilters); const handleChange = (e: React.ChangeEvent) => { @@ -68,13 +67,7 @@ const FilterComponent = (props: PropTypes) => { }; return ( - + {filter.filterDimensions.map((value: FilterDimension) => ( { Playground.args = { filter, viewAllLimit: 10, - hasSeparator: false, isViewingAll: false, }; diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx index 56f1dd6f..e2c72eee 100644 --- a/src/components/Filter/Filter.tsx +++ b/src/components/Filter/Filter.tsx @@ -4,10 +4,9 @@ import { Children, cloneElement, isValidElement, - ReactNode, + useState, Dispatch, SetStateAction, - useState, } from 'react'; import { getCommonProps, px } from '../../utils'; import classnames from 'classnames'; @@ -17,15 +16,10 @@ 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; - + /** Logical name of this filter */ name: string; - // Includes bottom border if true, false by default - hasSeparator?: boolean; - // Setter for values to display in view all setViewAllFilter?: Dispatch>; @@ -57,7 +51,6 @@ const Filter = forwardRef( className, children, name, - hasSeparator = false, viewAllLimit = 10, isViewingAll = false, isHidden = false, @@ -89,7 +82,6 @@ const Filter = forwardRef(
{
- {filters.map((filter: FilterType, index: number) => ( - + {filters.map((filter: FilterType) => ( + {Array.from(filter.filterDimensions).map((value: FilterDimension) => ( { const button = await screen.findByText(VIEW_ALL_BUTTON_TEXT); expect(button).toBeInTheDocument(); }); - - it('renders multiple filters and has separators', () => { - render( - - - - - - - - , - ); - - const filters = screen.getAllByTestId('filter'); - - expect(filters[0]).toHaveClass(`${px}-has-separators`); - expect(filters[1]).not.toHaveClass(`${px}-has-separators`); - }); }); diff --git a/src/patterns/FilterMenu/_filterMenu.scss b/src/patterns/FilterMenu/_filterMenu.scss index 017a18ed..5f692283 100644 --- a/src/patterns/FilterMenu/_filterMenu.scss +++ b/src/patterns/FilterMenu/_filterMenu.scss @@ -7,4 +7,9 @@ &::-webkit-scrollbar { display: none; } + .#{$px}-filter:not([hidden]) + .#{$px}-filter { + border-top: 1px solid $light-gray; + margin-top: $spacing-sm; + padding-top: $spacing-md; + } } From 76ff63a833d31e6952e8909a8f6f0ee16cf46cbd Mon Sep 17 00:00:00 2001 From: Scott Dickerson <6663002+scottdickerson@users.noreply.github.com> Date: Thu, 21 Nov 2024 13:00:40 -0600 Subject: [PATCH 10/11] fix(input): checkbox values were not being sent to the parent form --- src/components/Input/Input.stories.tsx | 30 ++++++++++++++++++++------ src/components/Input/Input.tsx | 5 ++--- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/components/Input/Input.stories.tsx b/src/components/Input/Input.stories.tsx index fb9e17e8..0aee24eb 100644 --- a/src/components/Input/Input.stories.tsx +++ b/src/components/Input/Input.stories.tsx @@ -1,6 +1,7 @@ import type { Meta } from '@storybook/react'; import Input, { InputProps } from './Input'; import { useState } from 'react'; +import Button from '../Button/Button'; // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction const meta = { title: 'Components/Input', @@ -214,11 +215,29 @@ ControlledInput.args = { ControlledInput.argTypes = argTypes; -export const Playground = ({ playgroundWidth, ...args }: StoryProps) => ( -
- -
-); +export const Playground = ({ playgroundWidth, ...args }: StoryProps) => { + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const entries: string[] = []; + for (const entry of formData.entries()) { + entries.push(`${entry[0]}: ${entry[1]}`); + } + alert(`Form submitted ${entries.join('\n')}`); + }; + + return ( +
+
+ + + + + +
+
+ ); +}; Playground.args = { playgroundWidth: 300, @@ -227,7 +246,6 @@ Playground.args = { invalid: false, invalidText: 'Error message', disabled: false, - defaultValue: 'My values', labelText: 'Label text', warn: false, warnText: 'Warning message that is really long can wrap to more lines.', diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index dcc062fc..2b5641fc 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -166,17 +166,16 @@ const Input = React.forwardRef( {inputProps.validation ? ( From 611a4232c83d5ca1b847faa3b088a353a4479bdd Mon Sep 17 00:00:00 2001 From: Scott Dickerson <6663002+scottdickerson@users.noreply.github.com> Date: Thu, 21 Nov 2024 13:44:58 -0600 Subject: [PATCH 11/11] fix(filter): small style fixes --- src/components/Filter/Filter.tsx | 17 +++-- src/components/Filter/_filter.scss | 75 ++++++++++++------- .../ViewingsList/ViewingsList.test.tsx | 8 +- .../ViewingsList/ViewingsListCardForm.tsx | 6 +- 4 files changed, 64 insertions(+), 42 deletions(-) diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx index e2c72eee..42f5b84c 100644 --- a/src/components/Filter/Filter.tsx +++ b/src/components/Filter/Filter.tsx @@ -8,9 +8,9 @@ import { Dispatch, SetStateAction, } from 'react'; -import { getCommonProps, px } from '../../utils'; +import { findChildrenExcludingTypes, findChildrenOfType, getCommonProps, px } from '../../utils'; import classnames from 'classnames'; -import FilterHeader, { FilterHeaderProps } from './FilterHeader'; +import FilterHeader from './FilterHeader'; import { FilterInputProps } from './FilterInput'; import Button from '../Button/Button'; import { ButtonVariants } from '../Button/types'; @@ -68,13 +68,14 @@ const Filter = forwardRef( const headerProps = { isViewingAll, setViewAllFilter, setIsClosing }; + const parsedFilterHeader = findChildrenOfType(childrenArray, FilterHeader)?.[0]; + const filterHeader = isValidElement(parsedFilterHeader) ? cloneElement(parsedFilterHeader, headerProps) : null; + // 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) => + const parsedFilterChildren = findChildrenExcludingTypes(childrenArray, [FilterHeader])?.map((child, index) => isValidElement(child) - ? child.type === FilterHeader - ? cloneElement(child, headerProps as FilterHeaderProps) - : cloneElement(child, { hidden: !isViewingAll && index > viewAllLimit } as FilterInputProps) + ? cloneElement(child, { hidden: !isViewingAll && index + 1 > viewAllLimit } as FilterInputProps) : child, ); @@ -89,10 +90,12 @@ const Filter = forwardRef( ref={ref} >
- {parsedFilterChildren} + {filterHeader} +
{parsedFilterChildren}
{childrenArray.length > viewAllLimit && !isViewingAll ? (