-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature/filter component #313
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import { Button } from '@digdir/designsystemet-react'; | ||
import { PlusIcon } from '@navikt/aksel-icons'; | ||
import { useTranslation } from 'react-i18next'; | ||
import { Filter, FilterBarField } from '../FilterBar.tsx'; | ||
import { FilterList, FilterListItem } from '../FilterList'; | ||
|
||
import styles from './addFilterButton.module.css'; | ||
|
||
type AddFilterButtonProps = { | ||
filterOptions: FilterBarField[]; | ||
filters: Filter[]; | ||
onListItemClick: (option: FilterBarField) => void; | ||
onBtnClick: () => void; | ||
isOpen: boolean; | ||
disabled?: boolean; | ||
}; | ||
export const AddFilterButton = ({ | ||
filterOptions, | ||
onListItemClick, | ||
disabled, | ||
filters, | ||
onBtnClick, | ||
isOpen, | ||
}: AddFilterButtonProps) => { | ||
const { t } = useTranslation(); | ||
return ( | ||
<div> | ||
<Button size="small" onClick={onBtnClick} disabled={disabled} variant="secondary" color="first"> | ||
<PlusIcon /> {t('filter_bar.add_filter')} | ||
</Button> | ||
{isOpen && ( | ||
<FilterList> | ||
{filterOptions.map((option: FilterBarField) => { | ||
const isUsed = !!filters.find((filter) => filter.fieldName === option.id); | ||
return ( | ||
<FilterListItem | ||
key={option.id} | ||
disabled={isUsed} | ||
onClick={() => { | ||
onListItemClick(option); | ||
}} | ||
> | ||
<div className={styles.addFilterButtonContent}> | ||
{option.leftIcon} | ||
<span className={styles.addFilterItemLabel}>{option.label}</span> | ||
<span className={styles.addFilterItemCount}>{option.options.length}</span> | ||
</div> | ||
</FilterListItem> | ||
); | ||
})} | ||
</FilterList> | ||
)} | ||
</div> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
.addFilterButtonContent { | ||
display: flex; | ||
align-items: center; | ||
justify-content: space-between; | ||
column-gap: 1em; | ||
} | ||
|
||
.addFilterItemLabel { | ||
font-size: 1rem; | ||
} | ||
|
||
.addFilterItemCount { | ||
padding: 0.5em; | ||
font-size: 0.75rem; | ||
font-weight: 600; | ||
line-height: 1em; | ||
border-radius: 50%; | ||
height: 1em; | ||
width: 1em; | ||
display: inline-flex; | ||
align-items: center; | ||
justify-content: center; | ||
background-color: rgb(243, 244, 246); | ||
color: rgba(0,0,0); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. flisespikk, men hvorfor ikke bare skrive "black" og spare å kjøre en funksjon? :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. det skal byttes uansett, koster ingenting |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { AddFilterButton } from './AddFilterButton.tsx'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,193 @@ | ||
import { useCallback, useState } from 'react'; | ||
import { AddFilterButton } from './AddFilterButton'; | ||
import { FilterButton } from './FilterButton'; | ||
import styles from './filterBar.module.css'; | ||
|
||
export type FieldOptionOperation = 'equals' | 'includes'; | ||
|
||
interface ValueFilter { | ||
fieldName: string | ((item: Record<string, string | number | boolean>) => string); | ||
operation: FieldOptionOperation; | ||
value: string; | ||
label: string; | ||
} | ||
|
||
export interface UnsetValueFilter { | ||
fieldName: string; | ||
operation: 'unset'; | ||
label: string; | ||
value?: string | string[]; | ||
} | ||
|
||
export type Filter = ValueFilter | UnsetValueFilter; | ||
|
||
export interface FilterBarFieldOption { | ||
id: string; | ||
label: string; | ||
operation: FieldOptionOperation; | ||
value: string; | ||
count: number; | ||
} | ||
|
||
export interface FilterBarField { | ||
label: string; | ||
unSelectedLabel: string; | ||
id: string; | ||
options: FilterBarFieldOption[]; | ||
chosenOptionId?: string | string[]; | ||
leftIcon?: React.ReactNode; | ||
} | ||
|
||
interface FilterBarProps { | ||
fields: FilterBarField[]; | ||
onFilterChange: (filters: Filter[]) => void; | ||
initialFilters?: Filter[]; | ||
} | ||
|
||
type ListOpenTarget = 'none' | string | 'add_filter'; | ||
|
||
/** | ||
* `FilterBar` is a component that renders a dynamic filter UI, allowing users to add, remove, and modify filters based on predefined field options. It supports both value-based filters and unset filters, where the former applies a specific condition (e.g., equals, includes) to a field, and the latter signifies the absence of a filter on that field. | ||
* | ||
* The component is designed to be flexible, accommodating a variety of filter types through its configuration props. It manages its own state for active filters and the visibility of filter option lists, providing a callback for when the active filters change, enabling parent components to react to updates in the filter state. | ||
* | ||
* Props: | ||
* - `fields`: Array of `FilterBarField` objects that define the available filters and their options. Each field includes a label, an ID, options (each with a label, value, and count), and optionally a chosen option ID and a left icon. | ||
* - `onFilterChange`: Function called with the current array of active `Filter` objects whenever the active filters change, allowing parent components to respond to filter changes. | ||
* - `initialFilters`: (Optional) Array of `Filter` objects representing the initial state of active filters when the component mounts. | ||
* | ||
* The component renders a list of `FilterButton` components for each active filter, allowing users to remove filters or change their values. An `AddFilterButton` is also rendered, enabling the addition of new filters from the available `fields`. | ||
* | ||
* Usage: | ||
* The `FilterBar` is intended to be used in applications requiring dynamic filtering capabilities, such as data tables or lists that need to be filtered based on various criteria. It is designed to be integrated into a larger UI, where it can be positioned as needed to provide filtering functionality. | ||
* | ||
* Example: | ||
* ``` | ||
* <FilterBar | ||
* fields={[ | ||
* { | ||
* label: 'Country', | ||
* unSelectedLabel: 'All Countries', | ||
* id: 'country', | ||
* options: [ | ||
* { id: 'us', label: 'United States', value: 'United States', count: 10, operation: 'equals' }, | ||
* { id: 'ca', label: 'Canada', value: 'Canada', count: 5, operation: 'equals' } | ||
* ] | ||
* }, | ||
* { | ||
* label: 'Gender', | ||
* unSelectedLabel: 'All Genders', | ||
* id: 'gender', | ||
* options: [ | ||
* { id: 'male', label: 'Male', value: 'Male', count: 15, operation: 'equals' }, | ||
* { id: 'female', label: 'Female', value: 'Female', count: 20, operation: 'equals' } | ||
* ] | ||
* } | ||
* ]} | ||
* onFilterChange={(filters) => console.log('Active filters:', filters)} | ||
* /> | ||
* ``` | ||
*/ | ||
export const FilterBar = ({ onFilterChange, fields, initialFilters = [] }: FilterBarProps) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe we could write a unit test for this component? |
||
const [activeFilters, setActiveFilters] = useState<Filter[]>(initialFilters); | ||
const [listOpenTarget, setListOpenTarget] = useState<ListOpenTarget>('none'); | ||
|
||
const handleOnRemove = useCallback( | ||
(fieldName: string) => { | ||
const updatedFilters = activeFilters.filter((filter) => filter.fieldName !== fieldName); | ||
setActiveFilters(updatedFilters); | ||
onFilterChange(updatedFilters); | ||
}, | ||
[activeFilters, onFilterChange], | ||
); | ||
|
||
const handleFilterUpdate = useCallback( | ||
(fieldName: string, option?: FilterBarFieldOption) => { | ||
const filterFound = !!activeFilters.find((filter) => filter.fieldName === fieldName); | ||
let newFilters = activeFilters.slice(); | ||
if (!filterFound && !option) { | ||
newFilters = [ | ||
...activeFilters, | ||
{ | ||
fieldName, | ||
operation: 'unset', | ||
label: '', | ||
}, | ||
]; | ||
} else if (filterFound && option) { | ||
newFilters = activeFilters.map((filter) => { | ||
if (filter.fieldName === fieldName) { | ||
return { | ||
fieldName, | ||
value: option.value, | ||
label: option.label, | ||
operation: option.operation, | ||
}; | ||
} | ||
return filter; | ||
}); | ||
} | ||
|
||
setActiveFilters(newFilters); | ||
onFilterChange(newFilters); | ||
}, | ||
[activeFilters, onFilterChange], | ||
); | ||
|
||
return ( | ||
<section className={styles.filterBar}> | ||
{activeFilters.map((filter) => { | ||
const filterOption = fields.find((field) => field.id === filter.fieldName); | ||
if (!filterOption) { | ||
return null; | ||
} | ||
|
||
const displayLabel = Array.isArray(filter.value) ? `${filter.value.length} selected` : filter.label; | ||
|
||
return ( | ||
<FilterButton | ||
onRemove={() => handleOnRemove(filterOption.id)} | ||
isOpen={listOpenTarget === filterOption.id} | ||
onBtnClick={() => { | ||
setListOpenTarget(listOpenTarget === filterOption.id ? 'none' : filterOption.id); | ||
}} | ||
key={filterOption.id} | ||
filterOption={filterOption} | ||
onListItemClick={(id, option) => { | ||
handleFilterUpdate(id, option); | ||
setListOpenTarget('none'); | ||
}} | ||
displayLabel={displayLabel} | ||
/> | ||
); | ||
})} | ||
<AddFilterButton | ||
isOpen={listOpenTarget === 'add_filter'} | ||
onBtnClick={() => { | ||
setListOpenTarget(listOpenTarget === 'add_filter' ? 'none' : 'add_filter'); | ||
}} | ||
filterOptions={fields} | ||
filters={activeFilters} | ||
onListItemClick={(filterOpt) => { | ||
const chosenOption = | ||
typeof filterOpt?.chosenOptionId !== 'undefined' | ||
? filterOpt.options.find((option: FilterBarFieldOption) => option.id === filterOpt?.chosenOptionId) | ||
: undefined; | ||
handleFilterUpdate(filterOpt.id, chosenOption); | ||
setListOpenTarget(filterOpt.id); | ||
}} | ||
/> | ||
{listOpenTarget !== 'none' && ( | ||
<div | ||
className={styles.background} | ||
onKeyUp={(e) => { | ||
if (e.key === 'Enter') { | ||
setListOpenTarget('none'); | ||
} | ||
}} | ||
onClick={() => setListOpenTarget('none')} | ||
/> | ||
)} | ||
</section> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
import { Button } from '@digdir/designsystemet-react'; | ||
import { TrashIcon } from '@navikt/aksel-icons'; | ||
import { FilterBarField, FilterBarFieldOption } from '../FilterBar.tsx'; | ||
import { FilterList, FilterListItem } from '../FilterList'; | ||
|
||
import cx from 'classnames'; | ||
import { useState } from 'react'; | ||
import styles from './filterButton.module.css'; | ||
|
||
export interface BaseFilterButtonProps { | ||
filterOption: FilterBarField; | ||
onListItemClick: (id: string, option: FilterBarFieldOption) => void; | ||
isOpen: boolean; | ||
displayLabel?: string; | ||
onBtnClick: () => void; | ||
onRemove: (fieldName: string) => void; | ||
} | ||
|
||
export const FilterButton = ({ | ||
filterOption, | ||
onListItemClick, | ||
onBtnClick, | ||
isOpen, | ||
onRemove, | ||
displayLabel, | ||
}: BaseFilterButtonProps) => { | ||
const [hoveringDeleteBtn, setHoveringDeleteBtn] = useState(false); | ||
const { id, unSelectedLabel, options } = filterOption; | ||
const chosenDisplayLabel = displayLabel || unSelectedLabel; | ||
|
||
return ( | ||
<div className={styles.filterButton}> | ||
<div className={styles.buttons}> | ||
<Button onClick={onBtnClick} className={cx({ [styles.xed]: hoveringDeleteBtn })} size="small"> | ||
{chosenDisplayLabel} | ||
</Button> | ||
<Button | ||
size="small" | ||
onClick={() => { | ||
onRemove(id); | ||
}} | ||
onMouseEnter={() => { | ||
if (!hoveringDeleteBtn) { | ||
setHoveringDeleteBtn(true); | ||
} | ||
}} | ||
onMouseLeave={() => { | ||
if (hoveringDeleteBtn) { | ||
setHoveringDeleteBtn(false); | ||
} | ||
}} | ||
> | ||
<TrashIcon /> | ||
</Button> | ||
</div> | ||
{isOpen ? ( | ||
<FilterList> | ||
{options.map((option) => { | ||
return ( | ||
<FilterListItem | ||
key={option.label} | ||
onClick={() => { | ||
onListItemClick(id, option); | ||
}} | ||
> | ||
<div className={styles.filterListContent}> | ||
<span className={styles.filterListLabel}>{option.label}</span> | ||
<span className={styles.filterListCount}>{option.count}</span> | ||
</div> | ||
</FilterListItem> | ||
); | ||
})} | ||
</FilterList> | ||
) : null} | ||
</div> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
.filterButton { | ||
button { | ||
border-radius: 0; | ||
} | ||
} | ||
|
||
.buttons { | ||
display: flex; | ||
align-items: center; | ||
} | ||
|
||
.xed { | ||
text-decoration: line-through; | ||
} | ||
|
||
.filterListContent { | ||
display: flex; | ||
align-items: center; | ||
justify-content: space-between; | ||
column-gap: 1em; | ||
} | ||
|
||
.filterListLabel { | ||
font-size: 1rem; | ||
} | ||
|
||
.filterListCount { | ||
padding: 0.5em; | ||
font-size: 0.75rem; | ||
font-weight: 600; | ||
line-height: 1em; | ||
border-radius: 50%; | ||
height: 1em; | ||
width: 1em; | ||
display: inline-flex; | ||
align-items: center; | ||
justify-content: center; | ||
background-color: rgb(243, 244, 246); | ||
color: rgba(0,0,0); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potentially refactor this into a
<ButtonWithDropdown />
-component? To extract the button, isOpen and dropdown functionality.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Vi må se hvor gjenbrukbart det kan bli på sikt, og behovene. Ser ingen verdi i å skille ut dette som egen komponent foreløpig.