diff --git a/packages/frontend-design-poc/src/components/FilterBar/AddFilterButton/AddFilterButton.tsx b/packages/frontend-design-poc/src/components/FilterBar/AddFilterButton/AddFilterButton.tsx new file mode 100644 index 00000000..72e4860f --- /dev/null +++ b/packages/frontend-design-poc/src/components/FilterBar/AddFilterButton/AddFilterButton.tsx @@ -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 ( +
+ + {isOpen && ( + + {filterOptions.map((option: FilterBarField) => { + const isUsed = !!filters.find((filter) => filter.fieldName === option.id); + return ( + { + onListItemClick(option); + }} + > +
+ {option.leftIcon} + {option.label} + {option.options.length} +
+
+ ); + })} +
+ )} +
+ ); +}; diff --git a/packages/frontend-design-poc/src/components/FilterBar/AddFilterButton/addFilterButton.module.css b/packages/frontend-design-poc/src/components/FilterBar/AddFilterButton/addFilterButton.module.css new file mode 100644 index 00000000..1477885e --- /dev/null +++ b/packages/frontend-design-poc/src/components/FilterBar/AddFilterButton/addFilterButton.module.css @@ -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); +} \ No newline at end of file diff --git a/packages/frontend-design-poc/src/components/FilterBar/AddFilterButton/index.ts b/packages/frontend-design-poc/src/components/FilterBar/AddFilterButton/index.ts new file mode 100644 index 00000000..50448b8c --- /dev/null +++ b/packages/frontend-design-poc/src/components/FilterBar/AddFilterButton/index.ts @@ -0,0 +1 @@ +export { AddFilterButton } from './AddFilterButton.tsx'; diff --git a/packages/frontend-design-poc/src/components/FilterBar/FilterBar.tsx b/packages/frontend-design-poc/src/components/FilterBar/FilterBar.tsx new file mode 100644 index 00000000..234e5ea8 --- /dev/null +++ b/packages/frontend-design-poc/src/components/FilterBar/FilterBar.tsx @@ -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); + 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: + * ``` + * console.log('Active filters:', filters)} + * /> + * ``` + */ +export const FilterBar = ({ onFilterChange, fields, initialFilters = [] }: FilterBarProps) => { + const [activeFilters, setActiveFilters] = useState(initialFilters); + const [listOpenTarget, setListOpenTarget] = useState('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 ( +
+ {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 ( + 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} + /> + ); + })} + { + 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' && ( +
{ + if (e.key === 'Enter') { + setListOpenTarget('none'); + } + }} + onClick={() => setListOpenTarget('none')} + /> + )} +
+ ); +}; diff --git a/packages/frontend-design-poc/src/components/FilterBar/FilterButton/FilterButton.tsx b/packages/frontend-design-poc/src/components/FilterBar/FilterButton/FilterButton.tsx new file mode 100644 index 00000000..4d40421a --- /dev/null +++ b/packages/frontend-design-poc/src/components/FilterBar/FilterButton/FilterButton.tsx @@ -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 ( +
+
+ + +
+ {isOpen ? ( + + {options.map((option) => { + return ( + { + onListItemClick(id, option); + }} + > +
+ {option.label} + {option.count} +
+
+ ); + })} +
+ ) : null} +
+ ); +}; diff --git a/packages/frontend-design-poc/src/components/FilterBar/FilterButton/filterButton.module.css b/packages/frontend-design-poc/src/components/FilterBar/FilterButton/filterButton.module.css new file mode 100644 index 00000000..b5cc2fca --- /dev/null +++ b/packages/frontend-design-poc/src/components/FilterBar/FilterButton/filterButton.module.css @@ -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); +} diff --git a/packages/frontend-design-poc/src/components/FilterBar/FilterButton/index.ts b/packages/frontend-design-poc/src/components/FilterBar/FilterButton/index.ts new file mode 100644 index 00000000..11c9970c --- /dev/null +++ b/packages/frontend-design-poc/src/components/FilterBar/FilterButton/index.ts @@ -0,0 +1,2 @@ +export { FilterButton } from './FilterButton.tsx'; +export type { BaseFilterButtonProps } from './FilterButton.tsx'; diff --git a/packages/frontend-design-poc/src/components/FilterBar/FilterList/FilterList.tsx b/packages/frontend-design-poc/src/components/FilterBar/FilterList/FilterList.tsx new file mode 100644 index 00000000..4415fc71 --- /dev/null +++ b/packages/frontend-design-poc/src/components/FilterBar/FilterList/FilterList.tsx @@ -0,0 +1,8 @@ +import styles from './filterList.module.css'; + +interface FilterListProps { + children: React.ReactNode; +} +export const FilterList = ({ children }: FilterListProps) => { + return
    {children}
; +}; diff --git a/packages/frontend-design-poc/src/components/FilterBar/FilterList/FilterListItem.tsx b/packages/frontend-design-poc/src/components/FilterBar/FilterList/FilterListItem.tsx new file mode 100644 index 00000000..73cf98e0 --- /dev/null +++ b/packages/frontend-design-poc/src/components/FilterBar/FilterList/FilterListItem.tsx @@ -0,0 +1,23 @@ +import cx from 'classnames'; +import styles from './filterListItem.module.css'; +interface FilterListItemProps { + onClick: () => void; + children?: React.ReactNode; + disabled?: boolean; +} +export const FilterListItem = ({ children, onClick, disabled }: FilterListItemProps) => { + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && !disabled) { + onClick?.(); + } + }; + return ( +
  • + {children} +
  • + ); +}; diff --git a/packages/frontend-design-poc/src/components/FilterBar/FilterList/filterList.module.css b/packages/frontend-design-poc/src/components/FilterBar/FilterList/filterList.module.css new file mode 100644 index 00000000..7a1b726a --- /dev/null +++ b/packages/frontend-design-poc/src/components/FilterBar/FilterList/filterList.module.css @@ -0,0 +1,9 @@ +.filterList { + list-style: none; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + border-radius: 0.375rem; + padding: 0.5em 0; + position: absolute; + z-index: 2; + background: white; +} \ No newline at end of file diff --git a/packages/frontend-design-poc/src/components/FilterBar/FilterList/filterListItem.module.css b/packages/frontend-design-poc/src/components/FilterBar/FilterList/filterListItem.module.css new file mode 100644 index 00000000..ac10e928 --- /dev/null +++ b/packages/frontend-design-poc/src/components/FilterBar/FilterList/filterListItem.module.css @@ -0,0 +1,12 @@ +.filterListItem { + cursor: pointer; + &:hover:not(.disabled){ + background: rgb(243, 244, 246); + } + padding: 0.6em 1em; +} + +.disabled { + opacity: 0.5; + pointer-events: none; +} \ No newline at end of file diff --git a/packages/frontend-design-poc/src/components/FilterBar/FilterList/index.ts b/packages/frontend-design-poc/src/components/FilterBar/FilterList/index.ts new file mode 100644 index 00000000..8b2a61af --- /dev/null +++ b/packages/frontend-design-poc/src/components/FilterBar/FilterList/index.ts @@ -0,0 +1,2 @@ +export { FilterList } from './FilterList.tsx'; +export { FilterListItem } from './FilterListItem.tsx'; diff --git a/packages/frontend-design-poc/src/components/FilterBar/filterBar.module.css b/packages/frontend-design-poc/src/components/FilterBar/filterBar.module.css new file mode 100644 index 00000000..df7f23b3 --- /dev/null +++ b/packages/frontend-design-poc/src/components/FilterBar/filterBar.module.css @@ -0,0 +1,17 @@ +.filterBar { + padding: 1em; + display: flex; + align-items: flex-start; + column-gap: 1em; +} + +.background { + position: fixed; + height: 100vh; + width: 100vw; + left: 0; + top: 0; + z-index: 1; + opacity: 0.1; + background: black; +} \ No newline at end of file diff --git a/packages/frontend-design-poc/src/components/FilterBar/index.ts b/packages/frontend-design-poc/src/components/FilterBar/index.ts new file mode 100644 index 00000000..b0898e7e --- /dev/null +++ b/packages/frontend-design-poc/src/components/FilterBar/index.ts @@ -0,0 +1,2 @@ +export { FilterBar } from './FilterBar.tsx'; +export { type Filter } from './FilterBar.tsx'; diff --git a/packages/frontend-design-poc/src/i18n/resources/nb.json b/packages/frontend-design-poc/src/i18n/resources/nb.json index 08579516..47634590 100644 --- a/packages/frontend-design-poc/src/i18n/resources/nb.json +++ b/packages/frontend-design-poc/src/i18n/resources/nb.json @@ -13,6 +13,7 @@ "sidebar.deleted": "Slettet", "sidebar.saved_searches": "Lagrede søk", "sidebar.settings": "Innstillinger", + "filter_bar.add_filter": "Legg til", "footer.nav.about_altinn": "Om Altinn", "footer.nav.service_messages": "Driftsmeldinger", "footer.nav.privacy_policy": "Personvern", diff --git a/packages/storybook/src/stories/FilterBar/filterbar.stories.tsx b/packages/storybook/src/stories/FilterBar/filterbar.stories.tsx new file mode 100644 index 00000000..784f68c2 --- /dev/null +++ b/packages/storybook/src/stories/FilterBar/filterbar.stories.tsx @@ -0,0 +1,300 @@ +import { FlagCrossIcon, HourglassIcon, PersonIcon } from '@navikt/aksel-icons'; +import { Meta, StoryObj } from '@storybook/react'; +import { type Filter, FilterBar } from 'frontend-design-poc/src/components/FilterBar'; +import { FilterBarField } from 'frontend-design-poc/src/components/FilterBar/FilterBar.tsx'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { withRouter } from 'storybook-addon-react-router-v6'; + +type Person = { + id: string; + name: string; + country: string; + gender: 'Male' | 'Female'; + yearOfBirth: number; +}; + +const historicalPeople: Person[] = [ + { + id: '1', + name: 'Marie Curie', + country: 'Poland', + gender: 'Female', + yearOfBirth: 1867, + }, + { + id: '2', + name: 'Albert Einstein', + country: 'Germany', + gender: 'Male', + yearOfBirth: 1879, + }, + { + id: '3', + name: 'Ada Lovelace', + country: 'United Kingdom', + gender: 'Female', + yearOfBirth: 1815, + }, + { + id: '4', + name: 'Isaac Newton', + country: 'United Kingdom', + gender: 'Male', + yearOfBirth: 1643, + }, + { + id: '5', + name: 'Nikola Tesla', + country: 'Croatia', + gender: 'Male', + yearOfBirth: 1856, + }, + { + id: '6', + name: 'Rosalind Franklin', + country: 'United Kingdom', + gender: 'Female', + yearOfBirth: 1920, + }, + { + id: '7', + name: 'Leonardo da Vinci', + country: 'Italy', + gender: 'Male', + yearOfBirth: 1452, + }, + { + id: '8', + name: 'Galileo Galilei', + country: 'Italy', + gender: 'Male', + yearOfBirth: 1564, + }, + { + id: '9', + name: 'Sophie Germain', + country: 'France', + gender: 'Female', + yearOfBirth: 1776, + }, + { + id: '10', + name: 'Charles Darwin', + country: 'United Kingdom', + gender: 'Male', + yearOfBirth: 1809, + }, +]; + +export default { + title: 'Components/FilterBar', + component: FilterBar, + decorators: [withRouter], + parameters: { + layout: 'fullscreen', + docs: { source: { type: 'code' } }, // Important: https://github.com/storybookjs/storybook/issues/19575 + }, +} as Meta; + +function countOccurrences(array: string[]): Record { + return array.reduce( + (acc, item) => { + acc[item] = (acc[item] || 0) + 1; + return acc; + }, + {} as Record, + ); +} + +const Template: StoryObj = { + render: (args) => { + const [filteredPeople, setFilteredPeople] = useState(historicalPeople); + + const handleFilterChange = useCallback((filters: Filter[]) => { + setFilteredPeople( + historicalPeople.filter((person) => { + return filters.every((filter) => { + if (Array.isArray(filter.value)) { + return filter.value.includes(String(person[filter.fieldName as keyof Person])); + } + if (typeof filter.value === 'string') { + if (filter.fieldName === 'yearOfBirth') { + const personCentury = `${Math.ceil(person.yearOfBirth / 100)}th Century`; + return filter.value === personCentury; + } + return filter.value === person[filter.fieldName as keyof Person]; + } + return true; + }); + }), + ); + }, []); + + const fields: FilterBarField[] = useMemo(() => { + return [ + { + id: 'country', + label: 'Country', + unSelectedLabel: 'All Countries', + leftIcon: , + options: (() => { + const countries = filteredPeople.map((p) => p.country); + const countryCounts = countOccurrences(countries); + return Array.from(new Set(countries)).map((country) => ({ + id: country, + label: country, + value: country, + count: countryCounts[country], + operation: 'equals', + })); + })(), + }, + { + id: 'gender', + label: 'Gender', + unSelectedLabel: 'All Genders', + leftIcon: , + options: (() => { + const genders = filteredPeople.map((p) => p.gender); + const genderCounts = countOccurrences(genders); + return Array.from(new Set(genders)).map((gender) => ({ + id: gender, + label: gender, + value: gender, + count: genderCounts[gender], + operation: 'equals', + })); + })(), + }, + { + id: 'yearOfBirth', + label: 'Century', + unSelectedLabel: 'All Centuries', + leftIcon: , + options: (() => { + const centuries = filteredPeople.map((person) => String(Math.ceil(person.yearOfBirth / 100))); + const centuryCounts = countOccurrences(centuries); + return Array.from(new Set(centuries)).map((century) => ({ + id: String(century), + label: `${century}th Century`, + value: `${century}th Century`, + count: centuryCounts[century], + operation: 'equals', + })); + })(), + }, + ]; + }, [filteredPeople]); + + return ( +
    + +
      + {filteredPeople.map((person) => ( +
    • + Name: {person.name}, Country: {person.country}, Gender: + {person.gender}, yearOfBirth: + {person.yearOfBirth} +
    • + ))} +
    +
    + ); + }, + args: { + fields: [], + }, +}; + +export const Default = Template; + +const initialState: Filter[] = [ + { + fieldName: 'country', + operation: 'equals', + value: 'Poland', + label: 'Poland', + }, +]; +export const InitialState: StoryObj = { + render: (args) => { + const [filteredPeople, setFilteredPeople] = useState(historicalPeople); + const filterPeople = useCallback((filters: Filter[]) => { + return historicalPeople.filter((person) => { + return filters.every((filter) => { + if (Array.isArray(filter.value)) { + return filter.value.includes(String(person[filter.fieldName as keyof Person])); + } + if (typeof filter.value === 'string') { + return filter.value === person[filter.fieldName as keyof Person]; + } + return true; + }); + }); + }, []); + const handleFilterChange = (filters: Filter[]) => { + setFilteredPeople(filterPeople(filters)); + }; + + useEffect(() => { + setFilteredPeople(filterPeople(initialState)); + }, [filterPeople]); + + const fields: FilterBarField[] = useMemo(() => { + return [ + { + id: 'country', + label: 'Country', + unSelectedLabel: 'All Countries', + leftIcon: , + options: (() => { + const countries = filteredPeople.map((p) => p.country); + const countryCounts = countOccurrences(countries); + return Array.from(new Set(countries)).map((country) => ({ + id: country, + label: country, + value: country, + count: countryCounts[country], + operation: 'equals', + })); + })(), + }, + { + id: 'gender', + label: 'Gender', + unSelectedLabel: 'All Genders', + leftIcon: , + options: (() => { + const genders = filteredPeople.map((p) => p.gender); + const genderCounts = countOccurrences(genders); + return Array.from(new Set(genders)).map((gender) => ({ + id: gender, + label: gender, + value: gender, + count: genderCounts[gender], + operation: 'equals', + })); + })(), + }, + ]; + }, [filteredPeople]); + + return ( +
    + +
      + {filteredPeople.map((person) => ( +
    • + Name: {person.name}, Country: {person.country}, Gender: + {person.gender}, yearOfBirth: + {person.yearOfBirth} +
    • + ))} +
    +
    + ); + }, + args: { + fields: [], + }, +};