From 82edff4fdf157d1e0e93d5546142f63dc8178174 Mon Sep 17 00:00:00 2001 From: Innders <49156310+Innders@users.noreply.github.com> Date: Sun, 12 Jan 2025 08:27:51 +0000 Subject: [PATCH 1/2] feat: search filter component --- package.json | 3 + src/SearchFilter/SearchFilter.stories.tsx | 38 ++ src/SearchFilter/SearchFilter.styled.ts | 56 +++ src/SearchFilter/SearchFilter.tsx | 396 +++++++++++++++++ .../SearchFilterDropdown.styled.ts | 152 +++++++ .../SearchFilterDropdown.tsx | 398 ++++++++++++++++++ src/SearchFilter/SearchFilterItem.tsx | 186 ++++++++ src/SearchFilter/SearchFilterItemValue.tsx | 77 ++++ src/SearchFilter/buildFilterId.ts | 3 + src/SearchFilter/checkColorBrightness.ts | 101 +++++ src/SearchFilter/doesFilterExist.ts | 9 + src/SearchFilter/getFilterFromId.ts | 1 + src/SearchFilter/hooks.ts | 18 + src/SearchFilter/index.tsx | 2 + src/SearchFilter/types.ts | 46 ++ 15 files changed, 1486 insertions(+) create mode 100644 src/SearchFilter/SearchFilter.stories.tsx create mode 100644 src/SearchFilter/SearchFilter.styled.ts create mode 100644 src/SearchFilter/SearchFilter.tsx create mode 100644 src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.styled.ts create mode 100644 src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.tsx create mode 100644 src/SearchFilter/SearchFilterItem.tsx create mode 100644 src/SearchFilter/SearchFilterItemValue.tsx create mode 100644 src/SearchFilter/buildFilterId.ts create mode 100644 src/SearchFilter/checkColorBrightness.ts create mode 100644 src/SearchFilter/doesFilterExist.ts create mode 100644 src/SearchFilter/getFilterFromId.ts create mode 100644 src/SearchFilter/hooks.ts create mode 100644 src/SearchFilter/index.tsx create mode 100644 src/SearchFilter/types.ts diff --git a/package.json b/package.json index e13ff8b..0b55d81 100644 --- a/package.json +++ b/package.json @@ -125,5 +125,8 @@ }, "resolutions": { "jackspeak": "2.1.1" + }, + "dependencies": { + "short-uuid": "^5.2.0" } } diff --git a/src/SearchFilter/SearchFilter.stories.tsx b/src/SearchFilter/SearchFilter.stories.tsx new file mode 100644 index 0000000..8114a17 --- /dev/null +++ b/src/SearchFilter/SearchFilter.stories.tsx @@ -0,0 +1,38 @@ +import { Meta, StoryObj } from '@storybook/react' +import { SearchFilter } from './SearchFilter' +import { useState } from 'react' +import { Filter, Option } from './types' + +const meta: Meta = { + component: SearchFilter, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +const options: Option[] = [ + { + id: 'status', + label: 'Status', + values: [ + { id: 'waiting', label: 'Waiting', color: '#FFA500', icon: 'hourglass_empty' }, + { id: 'inProgress', label: 'In Progress', color: '#4CAF50', icon: 'play_circle' }, + { id: 'completed', label: 'Completed', color: '#2196F3', icon: 'check_circle' }, + { id: 'error', label: 'Error', color: '#F44336', icon: 'error' }, + { id: 'paused', label: 'Paused', color: '#9C27B0', icon: 'pause_circle' }, + { id: 'cancelled', label: 'Cancelled', color: '#757575', icon: 'cancel' }, + ], + }, +] + +const Template = (args: Story['args']) => { + const [filters, setFilters] = useState([]) + + return +} + +export const Default: Story = { + args: {}, + render: Template, +} diff --git a/src/SearchFilter/SearchFilter.styled.ts b/src/SearchFilter/SearchFilter.styled.ts new file mode 100644 index 0000000..8a67a78 --- /dev/null +++ b/src/SearchFilter/SearchFilter.styled.ts @@ -0,0 +1,56 @@ +import styled from 'styled-components' +import { Button } from '../Button' + +export const Container = styled.div` + position: relative; + width: 100%; +` + +export const SearchBar = styled.div` + display: flex; + + padding: 3px 8px; + height: 32px; + align-items: center; + gap: var(--base-gap-small); + + border-radius: var(--border-radius-m); + border: 1px solid var(--md-sys-color-outline-variant); + background-color: var(--md-sys-color-surface-container-low); + + position: relative; + z-index: 301; + overflow: hidden; + + cursor: pointer; + + &:hover { + background-color: var(--md-sys-color-surface-container-low-hover); + } + + &:has(.search-filter-item:hover) { + background-color: var(--md-sys-color-surface-container-low); + } +` + +export const SearchBarFilters = styled.div` + display: flex; + gap: var(--base-gap-small); + white-space: nowrap; +` + +export const FilterButton = styled(Button)` + &.hasIcon { + padding: 2px; + } +` + +export const Backdrop = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + + z-index: 300; +` diff --git a/src/SearchFilter/SearchFilter.tsx b/src/SearchFilter/SearchFilter.tsx new file mode 100644 index 0000000..4e64221 --- /dev/null +++ b/src/SearchFilter/SearchFilter.tsx @@ -0,0 +1,396 @@ +import { FC, useRef, useState } from 'react' +import { Filter, FilterOperator, Option } from './types' +import * as Styled from './SearchFilter.styled' +import { SearchFilterItem } from './SearchFilterItem' +import SearchFilterDropdown, { + getIsValueSelected, + SearchFilterDropdownProps, +} from './SearchFilterDropdown/SearchFilterDropdown' +import { useFocusOptions } from './hooks' +import buildFilterId from './buildFilterId' +import getFilterFromId from './getFilterFromId' +import doesFilterExist from './doesFilterExist' +import { Icon } from '../Icon' + +const sortSelectedToTopFields = ['assignee', 'taskType'] + +export interface SearchFilterProps { + filters: Filter[] + onChange: (filters: Filter[]) => void + options: Option[] + onFinish?: (filters: Filter[]) => void + allowGlobalSearch?: boolean + allowMultipleSameFilters?: boolean + disabledFilters?: string[] // filters that should be disabled from adding, editing, or removing + preserveOrderFields?: string[] +} + +export const SearchFilter: FC = ({ + filters = [], + onChange, + onFinish, + options: initOptions = [], + allowGlobalSearch = false, + allowMultipleSameFilters = false, + disabledFilters, + preserveOrderFields, +}) => { + const filtersRef = useRef(null) + const dropdownRef = useRef(null) + + const options = getOptionsWithSearch(initOptions, allowGlobalSearch) + + const [dropdownParentId, setDropdownParentId] = useState(null) + const [dropdownOptions, setOptions] = useState(null) + + const parentOption = options.find( + (option) => dropdownParentId && option.id === getFilterFromId(dropdownParentId), + ) + + useFocusOptions({ ref: dropdownRef, options: dropdownOptions }) + + const openOptions = (options: Option[], parentId: string | null) => { + setOptions(options) + setDropdownParentId(parentId) + } + + const openInitialOptions = () => { + openOptions( + getShownRootOptions(options, filters, allowMultipleSameFilters, disabledFilters), + null, + ) + } + + const closeOptions = () => { + setOptions(null) + setDropdownParentId(null) + } + + const handleClose = (filters: Filter[]) => { + // remove any filters that have no values + const updatedFilters = filters.filter((filter) => filter.values && filter.values.length > 0) + onChange(updatedFilters) + + // set dropdownOptions to null + closeOptions() + // call onClose if it exists + onFinish && onFinish(updatedFilters) + + if (dropdownParentId) { + // find filter element by the id and focus it + document.getElementById(dropdownParentId)?.focus() + } else { + // focus last filter + const filters = filtersRef.current?.querySelectorAll('.search-filter-item') + const lastFilter = filters?.[filters.length - 1] as HTMLElement + lastFilter?.focus() + } + } + + const handleOptionSelect: SearchFilterDropdownProps['onSelect'] = (option, config) => { + const { values, parentId } = option + + // check if the filter already exists and if we allow multiple of the same filter + if (!allowMultipleSameFilters && doesFilterExist(option.id, filters)) return + + // create new id for the filter so we can add multiple of the same filter name + const newId = buildFilterId(option.id) + // check if there is a parent id + if (parentId) { + // find the parent filter + const parentFilter = filters.find((filter) => filter.id === parentId) + + // add to the parent filter values + if (parentFilter) { + const valueAlreadyExists = parentFilter.values?.some((val) => val.id === option.id) + + let updatedValues = + valueAlreadyExists && parentFilter.values + ? // If the option already exists, remove it + parentFilter.values.filter((val) => val.id !== option.id) + : // Otherwise, add the new option to the values array + [...(parentFilter.values || []), option] + + // if the option is hasValue or noValue, remove all other options + if (option.id === 'hasValue' || option.id === 'noValue') { + updatedValues = updatedValues.filter((val) => val.id === option.id) + } else { + // remove hasValue and noValue if a specific value is added + updatedValues = updatedValues.filter( + (val) => val.id !== 'hasValue' && val.id !== 'noValue', + ) + } + + // Create a new parent filter with the updated values + const updatedParentFilter = { + ...parentFilter, + values: updatedValues, + } + + // Update the filters array by replacing the parent filter with the updated one + const updatedFilters = filters.map((filter) => + filter.id === parentId ? updatedParentFilter : filter, + ) + + // Call the onChange callback with the updated filters + onChange(updatedFilters) + + if (config?.confirm && !config.restart) { + // close the dropdown with the new filters + handleClose(updatedFilters) + } else if (config?.restart) { + // go back to initial options + openInitialOptions() + } + } + } else { + const addFilter = { ...option, id: newId, values: [] } + // remove not required fields + delete addFilter.allowsCustomValues + // add to filters top level + onChange([...filters, addFilter]) + } + + // if there are values set the next dropdownOptions + // or the option allows custom values (text) + if ((values && values.length > 0 && !parentId) || option.allowsCustomValues) { + const newOptions = values?.map((value) => ({ ...value, parentId: newId })) || [] + + openOptions(newOptions, newId) + } + } + + const handleEditFilter = (id: string) => { + // find the filter option and set those values + const filter = filters.find((filter) => filter.id === id) + if (filter && filter.values && filter.values.length > 0) { + // Merge options with filter values to include custom values + const newOptions = mergeOptionsWithFilterValues(filter, options).map((value) => ({ + ...value, + parentId: id, + isSelected: getIsValueSelected(value.id, id, filters), + })) + + const filterName = getFilterFromId(id) + if (sortSelectedToTopFields.includes(filterName)) { + // sort selected to top + newOptions.sort((a, b) => { + if (a.isSelected && !b.isSelected) return -1 + if (!a.isSelected && b.isSelected) return 1 + return 0 + }) + } + openOptions(newOptions, id) + } else { + openOptions(options, id) + } + } + + const handleRemoveFilter = (id: string) => { + // remove a filter by id + const updatedFilters = filters.filter((filter) => filter.id !== id) + onChange(updatedFilters) + onFinish && onFinish(updatedFilters) + // close the dropdown + closeOptions() + } + + const handleInvertFilter = (id: string) => { + // find the filter and update the inverted value + const updatedFilters = filters.map((filter) => + filter.id === id ? { ...filter, inverted: !filter.inverted } : filter, + ) + onChange(updatedFilters) + onFinish && onFinish(updatedFilters) + } + + const handleFilterOperatorChange = (id: string, operator: FilterOperator) => { + // find the filter and update the operator value + const updatedFilters = filters.map((filter) => + filter.id === id ? { ...filter, operator } : filter, + ) + onChange(updatedFilters) + onFinish && onFinish(updatedFilters) + } + + const handleContainerKeyDown = (event: React.KeyboardEvent) => { + // cancel on esc + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + handleClose(filters) + } + } + + const handleSearchBarKeyDown = (event: React.KeyboardEvent) => { + // open on enter or space + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + event.stopPropagation() + openOptions(options, null) + } + // focus next item on arrow right / left + if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { + event.preventDefault() + event.stopPropagation() + const target = event.target as HTMLElement + let next = target.nextElementSibling as HTMLElement | null + while (next && !next.classList.contains('search-filter-item')) { + next = next.nextElementSibling as HTMLElement | null + if (next === null) break // Safeguard to prevent infinite loop + } + + let prev = target.previousElementSibling as HTMLElement | null + while (prev && !prev.classList.contains('search-filter-item')) { + prev = prev.previousElementSibling as HTMLElement | null + if (prev === null) break // Safeguard to prevent infinite loop + } + if (event.key === 'ArrowRight') { + next?.focus() + } else { + prev?.focus() + } + } + } + + // focus a different filter to edit + const handleSwitchFilterFocus = (direction: 'left' | 'right') => { + // get current filter from dropdownParentId + const filterIndex = filters.findIndex((filter) => filter.id === dropdownParentId) + // get next filter + const nextFilter = filters[filterIndex + (direction === 'right' ? 1 : -1)] + if (!nextFilter) return + + // open options for the next filter + handleEditFilter(nextFilter.id) + } + + const handleConfirmAndClose: SearchFilterDropdownProps['onConfirmAndClose'] = ( + filters, + config, + ) => { + if (config?.restart) { + // update filters + onChange(filters) + // go back to initial options + openInitialOptions() + + if (config.previous) { + // find the filter element by the id and focus it + // @ts-ignore + setTimeout(() => document.getElementById(config.previous)?.focus(), 50) + } + } else { + // close the dropdown + handleClose(filters) + } + } + + return ( + + {dropdownOptions && handleClose(filters)} />} + + + + {filters.map((filter, index) => ( + + ))} + + {filters.length ? ( + + ) : ( + {getEmptyPlaceholder(allowGlobalSearch)} + )} + + {dropdownOptions && ( + + )} + + ) +} + +const getEmptyPlaceholder = (allowGlobalSearch: boolean) => { + return allowGlobalSearch ? 'Search and filter' : 'Filter' +} +const getOptionsWithSearch = (options: Option[], allowGlobalSearch: boolean) => { + if (!allowGlobalSearch) return options + // unshift search option + const searchFilter: Option = { + id: 'text', + label: 'Text', + icon: 'manage_search', + inverted: false, + values: [], + allowsCustomValues: true, + } + + return [searchFilter, ...options] +} + +// get all the top level fields that should be shown depending on the filters and allowMultipleSameFilters and disabledFilters +const getShownRootOptions = ( + options: Option[], + filters: Filter[], + allowMultipleSameFilters: boolean, + disabledFilters: string[] = [], +): Option[] => { + return options.filter((option) => { + if (disabledFilters.includes(option.id)) return false + if (!allowMultipleSameFilters) { + return !doesFilterExist(option.id, filters) + } + return true + }) +} + +const mergeOptionsWithFilterValues = (filter: Filter, options: Option[]): Option[] => { + const filterName = getFilterFromId(filter.id) + const filterOptions = options.find((option) => option.id === filterName)?.values || [] + + const mergedOptions = [...filterOptions] + + filter.values?.forEach((value) => { + if (value.id === 'hasValue' || value.id === 'noValue') return + if (!mergedOptions.some((option) => option.id === value.id)) { + mergedOptions.push(value) + } + }) + + return mergedOptions +} diff --git a/src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.styled.ts b/src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.styled.ts new file mode 100644 index 0000000..f0bc478 --- /dev/null +++ b/src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.styled.ts @@ -0,0 +1,152 @@ +import styled from 'styled-components' +import { Icon } from '../../Icon' +import { Button } from '../../Button' + +export const OptionsContainer = styled.div` + position: absolute; + top: 40px; + left: 0; + right: 0; + overflow: hidden; + + border-radius: var(--border-radius-l); + background-color: var(--md-sys-color-surface-container-low); + border: 1px solid var(--md-sys-color-outline-variant); + + box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.25); + z-index: 301; +` + +export const Scrollable = styled.div` + overflow: auto; + height: 100%; + max-height: calc(min(350px, calc(100vh - 100px))); + padding: var(--padding-m); + + &:has(.toolbar) { + margin-bottom: 40px; + } +` + +export const OptionsList = styled.ul` + margin: 0; + padding: 0; + + display: flex; + flex-direction: column; + gap: var(--base-gap-small); +` + +export const Item = styled.li` + margin: 0; + list-style: none; + cursor: pointer; + user-select: none; + + width: 100%; + + display: flex; + align-items: center; + gap: var(--base-gap-large); + + padding: 6px; + border-radius: var(--border-radius-m); + + background-color: var(--md-sys-color-surface-container-low); + + &:hover { + background-color: var(--md-sys-color-surface-container-hover); + } + + &.selected { + background-color: var(--md-sys-color-primary-container); + &, + .icon { + color: var(--md-sys-color-on-primary-container); + } + + &:hover { + background-color: var(--md-sys-color-primary-container-hover); + } + } + + img { + width: 20px; + height: 20px; + border-radius: 50%; + } + + .check { + margin-left: auto; + } +` + +export const SearchContainer = styled.div` + position: relative; + width: 100%; +` + +export const SearchInput = styled.input` + /* remove default styles */ + appearance: none; + border: none; + background: none; + font: inherit; + + height: 32px; + width: 100%; + padding-left: 32px; + border-radius: var(--border-radius-m); + border: 1px solid var(--md-sys-color-outline-variant); +` + +export const SearchIcon = styled(Icon)` + position: absolute; + top: 50%; + left: 8px; + transform: translateY(-50%); +` + +export const AddSearch = styled(Button)` + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); + &.hasIcon { + padding: 2px 4px; + padding-right: 6px; + } +` + +export const Toolbar = styled.div` + background-color: var(--md-sys-color-surface-container-low); + left: 0; + right: 0; + + position: absolute; + bottom: 0; + padding: var(--padding-m); + /* padding-bottom: var(--padding-m); */ + + display: flex; + gap: var(--base-gap-large); + align-items: center; +` + +export const Operator = styled.div` + display: flex; + border-radius: var(--border-radius-m); + + .hasIcon { + padding-right: 16px; + } + + button:first-child { + border-radius: var(--border-radius-m) 0 0 var(--border-radius-m); + border-right: 1px solid var(--md-sys-color-outline); + } + + button:last-child { + border-radius: 0 var(--border-radius-m) var(--border-radius-m) 0; + } +` diff --git a/src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.tsx b/src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.tsx new file mode 100644 index 0000000..fffc0de --- /dev/null +++ b/src/SearchFilter/SearchFilterDropdown/SearchFilterDropdown.tsx @@ -0,0 +1,398 @@ +import { forwardRef, useMemo, useState } from 'react' +import { Filter, FilterOperator, Option } from '../types' +import * as Styled from './SearchFilterDropdown.styled' +import clsx from 'clsx' +import { matchSorter } from 'match-sorter' +import checkColorBrightness from '../checkColorBrightness' +import buildFilterId from '../buildFilterId' +import getFilterFromId from '../getFilterFromId' +import { Icon, IconType } from '../../Icon' +import { Button } from '../../Button' +import { Spacer } from '../../Layout/Spacer' +import { InputSwitch } from '../../Inputs/InputSwitch' + +type OnSelectConfig = { + confirm?: boolean + restart?: boolean + previous?: string // used to go back to the previous field along with restart +} + +export interface SearchFilterDropdownProps { + options: Option[] + values: Filter[] + parentId: string | null + parentLabel?: string + isCustomAllowed: boolean + isHasValueAllowed?: boolean + isNoValueAllowed?: boolean + isInvertedAllowed?: boolean + operatorChangeable?: boolean + preserveOrderFields?: string[] + onSelect: (option: Option, config?: OnSelectConfig) => void + onInvert: (id: string) => void // invert the filter + onOperatorChange?: (id: string, operator: FilterOperator) => void // change the operator + onConfirmAndClose?: (filters: Filter[], config?: OnSelectConfig) => void // close the dropdown and update the filters + onSwitchFilter?: (direction: 'left' | 'right') => void // switch to the next filter to edit +} + +const SearchFilterDropdown = forwardRef( + ( + { + options, + values, + parentId, + parentLabel, + isCustomAllowed, + isHasValueAllowed, + isNoValueAllowed, + isInvertedAllowed, + operatorChangeable, + preserveOrderFields = [], + onSelect, + onInvert, + onOperatorChange, + onConfirmAndClose, + onSwitchFilter, + }, + ref, + ) => { + const parentFilter = values.find((filter) => filter.id === parentId) + + const [search, setSearch] = useState('') + + // sort options based on selected, skipping certain fields + const sortedOptions = useMemo(() => { + // if the option has an icon, it is most likely an enum and do not sort + const anyIcons = options.some((option) => option.icon) + + // should we sort? + if (!parentId || preserveOrderFields.includes(parentId) || anyIcons) return options + + const selectedOptions = options.filter((option) => { + const isSelected = getIsValueSelected(option.id, parentId, values) + return isSelected + }) + const unselectedOptions = options.filter((option) => { + const isSelected = getIsValueSelected(option.id, parentId, values) + return !isSelected + }) + return [...selectedOptions, ...unselectedOptions] + }, [options]) + + // add any extra options + const allOptions = useMemo(() => { + let optionsList = [...sortedOptions] + if (parentId && isHasValueAllowed) { + optionsList = [ + { + id: 'hasValue', + label: `Has ${parentLabel}`, + parentId, + values: [], + icon: 'check', + }, + ...optionsList, + ] + } + if (parentId && isNoValueAllowed) { + optionsList = [ + { + id: 'noValue', + label: `No ${parentLabel}`, + parentId, + values: [], + icon: 'unpublished', + }, + ...optionsList, + ] + } + return optionsList + }, [sortedOptions, parentId, parentLabel, isHasValueAllowed, isNoValueAllowed]) + + // filter options based on search + const filteredOptions = useMemo( + () => getFilteredOptions(allOptions, search), + [allOptions, search], + ) + + const handleSelectOption = ( + event: React.MouseEvent | React.KeyboardEvent, + ) => { + event.preventDefault() + event.stopPropagation() + + const target = event.target as HTMLElement + const id = target.closest('li')?.id + + // get option by id + const option = allOptions.find((option) => option.id === id) + if (!option) return console.error('Option not found:', id) + + const closeOptions = + (option.id === 'hasValue' || option.id === 'noValue') && values.length === 0 + + onSelect(option, { confirm: closeOptions, restart: closeOptions }) + // clear search + setSearch('') + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + // cancel on esc + if ([' ', 'Enter'].includes(event.key)) { + event.preventDefault() + event.stopPropagation() + if (event.key === 'Enter') { + // shift + enter will confirm but keep the dropdown open + // any other enter will confirm and close dropdown + onConfirmAndClose && onConfirmAndClose(values, { restart: event.shiftKey }) + } + } + // up arrow + if (event.key === 'ArrowUp') { + event.preventDefault() + event.stopPropagation() + const target = event.target as HTMLElement + const prev = target.previousElementSibling as HTMLElement + // if the previous element is the search input, focus the input + if (prev?.classList.contains('search')) { + const input = prev.querySelector('input') as HTMLElement + input.focus() + } else { + prev?.focus() + } + } + // down arrow + if (event.key === 'ArrowDown') { + event.preventDefault() + event.stopPropagation() + const target = event.target as HTMLElement + const next = target.nextElementSibling as HTMLElement + next?.focus() + } + // arrow left or right + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault() + event.stopPropagation() + // trigger event to switch to next filter to edit, logic in parent + onSwitchFilter && onSwitchFilter(event.key === 'ArrowRight' ? 'right' : 'left') + } + + // back key + if (event.key === 'Backspace' && !search) { + event.preventDefault() + event.stopPropagation() + + if (!parentFilter?.values?.length && parentId) { + const previousField = getFilterFromId(parentId) + handleBack(previousField) + } + } + } + + const handleSearchSubmit = () => { + const addedOption = getAddOption(search, filteredOptions, parentId, isCustomAllowed) + if (!addedOption) return + + // add the first option + onSelect(addedOption, { confirm: true, restart: true }) + // clear search + setSearch('') + } + + const handleBack = (previousField?: string) => { + // remove the parentId value if the filter has no values + const newValues = values.filter( + (filter) => !(filter.id === parentId && !filter.values?.length), + ) + + onConfirmAndClose && onConfirmAndClose(newValues, { restart: true, previous: previousField }) + } + + const handleCustomSearchShortcut = () => { + // check there is a text option + const hasTextOption = allOptions.some((option) => option.id === 'text') + if (!hasTextOption) return + + const newId = buildFilterId('text') + + const newFilter: Filter = { + id: newId, + label: 'Text', + values: [{ id: search, label: search, parentId: newId, isCustom: true }], + } + + onConfirmAndClose && onConfirmAndClose([...values, newFilter]) + } + + const handleSearchKeyDown = (event: React.KeyboardEvent) => { + // enter will select the first option + if (event.key === 'Enter') { + event.preventDefault() + event.stopPropagation() + + if (search && !parentId && filteredOptions.length === 0) { + // if the root field search has no results, add the custom value as text + handleCustomSearchShortcut() + } else { + // otherwise, add the first option + handleSearchSubmit() + } + } + // arrow down will focus the first option + if (event.key === 'ArrowDown') { + event.preventDefault() + event.stopPropagation() + const target = event.target as HTMLElement + const next = target.parentElement?.nextElementSibling as HTMLElement + next?.focus() + } + } + + return ( + + + + + setSearch(event.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder={getSearchPlaceholder(isCustomAllowed, allOptions)} + autoFocus + /> + + {isCustomAllowed && ( + + Add + + )} + + {filteredOptions.map(({ id, parentId, label, icon, img, color }) => { + const isSelected = getIsValueSelected(id, parentId, values) + const adjustedColor = color ? checkColorBrightness(color, '#1C2026') : undefined + return ( + handleSelectOption(event)} + > + {icon && } + {img && {label}} + + {label} + + {isSelected && } + + ) + })} + {filteredOptions.length === 0 && !isCustomAllowed && No filters found} + {parentId && ( + + + + {isInvertedAllowed && ( + <> + Excludes + onInvert(parentId)} + /> + + )} + {operatorChangeable && ( + + {['AND', 'OR'].map((operator) => ( + + ))} + + )} + + + )} + + + + ) + }, +) + +export default SearchFilterDropdown + +export const getIsValueSelected = ( + id: string, + parentId?: string | null, + values?: Filter[], +): boolean => { + if (!parentId || !values) return false + // find the parent filter + const parentFilter = values.find((filter) => filter.id === parentId) + if (!parentFilter) return false + + // check if the value is already selected + return !!parentFilter.values?.some((value) => value.id === id) +} + +const getFilteredOptions = (options: Option[], search: string) => { + // filter out options that don't match the search in any of the fields + + // no search? return all options + if (!search) return options + + const parsedSearch = search.toLowerCase() + + return matchSorter(options, parsedSearch, { + keys: ['label', 'context', 'keywords'], + }) +} + +const getSearchPlaceholder = (isCustomAllowed: boolean, options: Option[]) => { + const somePreMadeOptions = options.length > 0 && options.some((option) => !option.isCustom) + + return !somePreMadeOptions && isCustomAllowed + ? 'Add filter text...' + : isCustomAllowed + ? 'Search or add filter text...' + : 'Search...' +} + +const getAddOption = ( + customValue: string, + options: Option[], + parentId: string | null, + isCustomAllowed?: boolean, +): Option | null => { + if (customValue && parentId && isCustomAllowed) { + // add custom value + return { id: customValue, label: customValue, values: [], parentId, isCustom: true } + } else if (!isCustomAllowed && options.length) { + return options[0] + } else { + return null + } +} diff --git a/src/SearchFilter/SearchFilterItem.tsx b/src/SearchFilter/SearchFilterItem.tsx new file mode 100644 index 0000000..a8b0328 --- /dev/null +++ b/src/SearchFilter/SearchFilterItem.tsx @@ -0,0 +1,186 @@ +import { forwardRef } from 'react' +import styled from 'styled-components' +import { Filter } from './types' +import { SearchFilterItemValue } from './SearchFilterItemValue' +import clsx from 'clsx' +import { Button, theme } from '..' + +const FilterItem = styled.div` + display: flex; + align-items: center; + gap: var(--base-gap-small); + user-select: none; + white-space: nowrap; + + background-color: var(--md-sys-color-surface-container-high); + padding: 2px 4px; + /* padding-right: 8px; */ + border-radius: 4px; + + cursor: pointer; + &:hover { + background-color: var(--md-sys-color-surface-container-high-hover); + + .button { + background-color: var(--md-sys-color-surface-container-highest-hover); + } + } + + &.editing { + outline: 2px solid #99c8ff; + } + + &.disabled { + pointer-events: none; + opacity: 0.5; + } +` + +const Operator = styled.span` + ${theme.labelSmall} + display: flex; + align-items: center; +` + +const ChipButton = styled(Button)` + border-radius: 50%; + background-color: unset; + + &:hover:not(.disabled) { + &.button { + background-color: var(--md-sys-color-primary); + } + .icon { + color: var(--md-sys-color-on-primary); + } + } + + &.hasIcon { + padding: 2px; + } + + .icon { + font-size: 16px; + } +` + +interface SearchFilterItemProps extends Omit, 'id'>, Filter { + index?: number + isEditing?: boolean + isInvertedAllowed?: boolean + isDisabled?: boolean + onEdit?: (id: string) => void + onRemove?: (id: string) => void + onInvert?: (id: string) => void +} + +export const SearchFilterItem = forwardRef( + ( + { + id, + label, + inverted, + operator, + values, + icon, + isCustom, + index, + isEditing, + isInvertedAllowed, + isDisabled, + isReadonly, + onEdit, + onRemove, + onInvert, + onClick, + ...props + }, + ref, + ) => { + const handleEdit = (id: string) => { + if (isReadonly) return + onEdit?.(id) + } + + const handleRemove = (event: React.MouseEvent) => { + // block main onClick event + event?.stopPropagation() + // remove filter + onRemove?.(id) + } + + const handleInvert = (event: React.MouseEvent) => { + if (!isInvertedAllowed) return + // block main onClick event + event?.stopPropagation() + // remove filter + onInvert?.(id) + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + // enter or space + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + event.stopPropagation() + handleEdit(id) + } + } + + // trigger onEdit callback and forward onClick event + const handleClick = (event: React.MouseEvent) => { + // stop propagation to opening whole search bar + event.stopPropagation() + handleEdit(id) + onClick && onClick(event) + } + + const operatorText = getOperatorText(index || 0, inverted) + + return ( + <> + {operatorText && {operatorText}} + + + {label}: + {values?.map((value, index) => ( + 0 ? operator : undefined} + isCompact={values.length > 1 && (!!value.icon || !!value.img)} + /> + ))} + {onRemove && } + + + ) + }, +) + +const getOperatorText = (index: number, inverted?: boolean): string | undefined => { + if (index > 0) { + return `and ${inverted ? 'not' : ''}` + } else if (inverted) { + return 'not' + } else { + return undefined + } +} diff --git a/src/SearchFilter/SearchFilterItemValue.tsx b/src/SearchFilter/SearchFilterItemValue.tsx new file mode 100644 index 0000000..f539768 --- /dev/null +++ b/src/SearchFilter/SearchFilterItemValue.tsx @@ -0,0 +1,77 @@ +import { forwardRef } from 'react' +import { FilterOperator, FilterValue } from './types' +import styled from 'styled-components' +import clsx from 'clsx' +import checkColorBrightness from './checkColorBrightness' +import { Icon, IconType } from '../Icon' +import { theme } from '..' + +const ValueChip = styled.div` + display: flex; + align-items: center; + gap: var(--base-gap-small); + border-radius: var(--border-radius-m); + + img { + width: 16px; + height: 16px; + border-radius: 50%; + } + + /* hide label */ + &.compact { + .label { + display: none; + } + } + + &.custom { + padding: 0 2px; + background-color: var(--md-sys-color-surface-container-high-hover); + } +` + +const Operator = styled.span` + ${theme.labelSmall} + display: flex; + align-items: center; +` + +interface SearchFilterItemValueProps + extends Omit, 'color' | 'id'>, + FilterValue { + operator?: FilterOperator + isCompact?: boolean +} + +export const SearchFilterItemValue = forwardRef( + ({ label, img, icon, color, operator, isCompact, isCustom, ...props }, ref) => { + const colorStyle = color ? color : '#ffffff' + const adjustedColor = checkColorBrightness(colorStyle, '#353B46') + + return ( + <> + {operator && {operator.toLowerCase()}} + + {icon && ( + + )} + {img && {label}} + + {label} + + + + ) + }, +) diff --git a/src/SearchFilter/buildFilterId.ts b/src/SearchFilter/buildFilterId.ts new file mode 100644 index 0000000..d4cd1dd --- /dev/null +++ b/src/SearchFilter/buildFilterId.ts @@ -0,0 +1,3 @@ +import { uuid } from 'short-uuid' + +export default (name: string) => `${name}_${uuid()}` diff --git a/src/SearchFilter/checkColorBrightness.ts b/src/SearchFilter/checkColorBrightness.ts new file mode 100644 index 0000000..a132322 --- /dev/null +++ b/src/SearchFilter/checkColorBrightness.ts @@ -0,0 +1,101 @@ +// accepts foreground hex color string and background color string +// ensures that the text is readable by checking the lightness of the background color against the lightness of the foreground color +// increase the lightness of the foreground color if it is too dark +function checkColorBrightness(foregroundHex: string, backgroundHex: string): string { + // Helper function to convert hex to HSL + function hexToHsl(hex: string): { h: number; s: number; l: number } { + let r = parseInt(hex.slice(1, 3), 16) / 255 + let g = parseInt(hex.slice(3, 5), 16) / 255 + let b = parseInt(hex.slice(5, 7), 16) / 255 + + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + let h = 0, + s = 0, + l = (max + min) / 2 + + if (max !== min) { + const d = max - min + s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0) + break + case g: + h = (b - r) / d + 2 + break + case b: + h = (r - g) / d + 4 + break + } + h /= 6 + } + + return { h: h * 360, s: s * 100, l: l * 100 } + } + + // Helper function to convert HSL to hex + function hslToHex(h: number, s: number, l: number): string { + s /= 100 + l /= 100 + + const c = (1 - Math.abs(2 * l - 1)) * s + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = l - c / 2 + let r = 0, + g = 0, + b = 0 + + if (0 <= h && h < 60) { + r = c + g = x + b = 0 + } else if (60 <= h && h < 120) { + r = x + g = c + b = 0 + } else if (120 <= h && h < 180) { + r = 0 + g = c + b = x + } else if (180 <= h && h < 240) { + r = 0 + g = x + b = c + } else if (240 <= h && h < 300) { + r = x + g = 0 + b = c + } else if (300 <= h && h < 360) { + r = c + g = 0 + b = x + } + + r = Math.round((r + m) * 255) + g = Math.round((g + m) * 255) + b = Math.round((b + m) * 255) + + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()}` + } + + // Convert hex colors to HSL + const foregroundHsl = hexToHsl(foregroundHex) + const backgroundHsl = hexToHsl(backgroundHex) + + const contrast = 50 + // If the foreground lightness is too low compared to the background, increase its lightness + if (foregroundHsl.l < backgroundHsl.l + contrast) { + // increase the lightness to make the color more visible + foregroundHsl.l = Math.min( + 100, + foregroundHsl.l + (backgroundHsl.l + contrast - foregroundHsl.l), + ) + // increase the saturation to make the color more vibrant + foregroundHsl.s = Math.min(100, foregroundHsl.s + 10) + } + + return hslToHex(foregroundHsl.h, foregroundHsl.s, foregroundHsl.l) +} + +export default checkColorBrightness diff --git a/src/SearchFilter/doesFilterExist.ts b/src/SearchFilter/doesFilterExist.ts new file mode 100644 index 0000000..c426c51 --- /dev/null +++ b/src/SearchFilter/doesFilterExist.ts @@ -0,0 +1,9 @@ +import getFilterFromId from './getFilterFromId' +import { Filter } from './types' + +const doesFilterExist = (filterId: string, filters: Filter[]) => { + const filterName = getFilterFromId(filterId) + return filters.some((filter) => getFilterFromId(filter.id) === filterName) +} + +export default doesFilterExist diff --git a/src/SearchFilter/getFilterFromId.ts b/src/SearchFilter/getFilterFromId.ts new file mode 100644 index 0000000..43a5372 --- /dev/null +++ b/src/SearchFilter/getFilterFromId.ts @@ -0,0 +1 @@ +export default (id: string) => id?.split('_')[0] diff --git a/src/SearchFilter/hooks.ts b/src/SearchFilter/hooks.ts new file mode 100644 index 0000000..7677fec --- /dev/null +++ b/src/SearchFilter/hooks.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react' +import { Option } from './types' + +type UseFocusOptions = { + ref: React.RefObject + options: Option[] | null +} + +export const useFocusOptions = ({ ref, options }: UseFocusOptions) => { + // map all ids into a string to be used to compare different dropdowns + const ids = options?.map((option) => option.id) + + useEffect(() => { + if (!ids) return + // focus search input + ref.current?.querySelector('input')?.focus() + }, [ref, ids?.join('_')]) +} diff --git a/src/SearchFilter/index.tsx b/src/SearchFilter/index.tsx new file mode 100644 index 0000000..4c82b8e --- /dev/null +++ b/src/SearchFilter/index.tsx @@ -0,0 +1,2 @@ +export * from './SearchFilter' +export * from './types' diff --git a/src/SearchFilter/types.ts b/src/SearchFilter/types.ts new file mode 100644 index 0000000..1e2d507 --- /dev/null +++ b/src/SearchFilter/types.ts @@ -0,0 +1,46 @@ +export type FilterValue = { + id: string + label: string + img?: string | null + icon?: string | null + color?: string | null + isCustom?: boolean + parentId?: string | null +} + +export type FilterOperator = 'AND' | 'OR' + +export type Filter = { + id: string + type?: + | 'string' + | 'integer' + | 'float' + | 'boolean' + | 'datetime' + | 'list_of_strings' + | 'list_of_integers' + | 'list_of_any' + | 'list_of_submodels' + | 'dict' + label: string + inverted?: boolean + operator?: FilterOperator + icon?: string | null + img?: string | null + values?: FilterValue[] + isCustom?: boolean + isReadonly?: boolean // can not be edited and only removed + singleSelect?: boolean + fieldType?: string +} + +export interface Option extends Filter { + allowNoValue?: boolean // allows the filter to have "no value" + allowHasValue?: boolean // allows the filter to have "has a value" + allowsCustomValues?: boolean // allows the filter to have custom values + allowExcludes?: boolean // allows the filter to be inverted + operatorChangeable?: boolean // allows the operator to be changed + color?: string | null // color of the filter (not used for root options) + parentId?: string | null // parent filter id +} From a5b9f162727ef5245a1b32cd84aa3b4599a18034 Mon Sep 17 00:00:00 2001 From: Innders <49156310+Innders@users.noreply.github.com> Date: Mon, 13 Jan 2025 07:29:00 +0000 Subject: [PATCH 2/2] fix(Filter): add story and fixes --- src/SearchFilter/SearchFilter.stories.tsx | 7 +++++++ src/SearchFilter/SearchFilter.tsx | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/src/SearchFilter/SearchFilter.stories.tsx b/src/SearchFilter/SearchFilter.stories.tsx index 8114a17..f41ea2c 100644 --- a/src/SearchFilter/SearchFilter.stories.tsx +++ b/src/SearchFilter/SearchFilter.stories.tsx @@ -15,6 +15,13 @@ const options: Option[] = [ { id: 'status', label: 'Status', + operator: 'OR', + allowExcludes: true, + allowHasValue: true, + allowNoValue: true, + allowsCustomValues: true, + operatorChangeable: true, + values: [ { id: 'waiting', label: 'Waiting', color: '#FFA500', icon: 'hourglass_empty' }, { id: 'inProgress', label: 'In Progress', color: '#4CAF50', icon: 'play_circle' }, diff --git a/src/SearchFilter/SearchFilter.tsx b/src/SearchFilter/SearchFilter.tsx index 4e64221..42bff71 100644 --- a/src/SearchFilter/SearchFilter.tsx +++ b/src/SearchFilter/SearchFilter.tsx @@ -121,9 +121,12 @@ export const SearchFilter: FC = ({ ) } + const operator = parentFilter.operator || 'OR' + // Create a new parent filter with the updated values const updatedParentFilter = { ...parentFilter, + operator, values: updatedValues, } @@ -332,6 +335,7 @@ export const SearchFilter: FC = ({ isHasValueAllowed={!!parentOption?.allowHasValue} isNoValueAllowed={!!parentOption?.allowNoValue} isInvertedAllowed={!!parentOption?.allowExcludes} + operatorChangeable={!!parentOption?.operatorChangeable} preserveOrderFields={preserveOrderFields} onSelect={handleOptionSelect} onInvert={handleInvertFilter}