diff --git a/.changeset/dull-dancers-hide.md b/.changeset/dull-dancers-hide.md new file mode 100644 index 000000000..5c39a4428 --- /dev/null +++ b/.changeset/dull-dancers-hide.md @@ -0,0 +1,8 @@ +--- +'@o2s/integrations.mocked': minor +'@o2s/blocks.ticket-list': minor +'@o2s/framework': minor +'@o2s/ui': minor +--- + +add inline filters variant with expandable sections diff --git a/packages/blocks/ticket-list/src/api-harmonization/ticket-list.model.ts b/packages/blocks/ticket-list/src/api-harmonization/ticket-list.model.ts index 5b8b99ed4..f9bf37e23 100644 --- a/packages/blocks/ticket-list/src/api-harmonization/ticket-list.model.ts +++ b/packages/blocks/ticket-list/src/api-harmonization/ticket-list.model.ts @@ -23,6 +23,9 @@ export class TicketListBlock extends ApiModels.Block.Block { labels!: { showMore: string; clickToSelect: string; + showMoreFilters?: string; + hideMoreFilters?: string; + noActiveFilters?: string; }; initialFilters?: Partial; meta?: CMS.Model.TicketListBlock.TicketListBlock['meta']; diff --git a/packages/blocks/ticket-list/src/api-harmonization/ticket-list.request.ts b/packages/blocks/ticket-list/src/api-harmonization/ticket-list.request.ts index 0546a952d..2548852ad 100644 --- a/packages/blocks/ticket-list/src/api-harmonization/ticket-list.request.ts +++ b/packages/blocks/ticket-list/src/api-harmonization/ticket-list.request.ts @@ -7,4 +7,6 @@ export class GetTicketListBlockQuery offset?: number; limit?: number; preview?: boolean; + search?: string; + priority?: string; } diff --git a/packages/blocks/ticket-list/src/frontend/TicketList.client.tsx b/packages/blocks/ticket-list/src/frontend/TicketList.client.tsx index 6694d517a..3c63cd812 100644 --- a/packages/blocks/ticket-list/src/frontend/TicketList.client.tsx +++ b/packages/blocks/ticket-list/src/frontend/TicketList.client.tsx @@ -38,6 +38,8 @@ export const TicketListPure: React.FC = ({ locale, accessTo id: component.id, offset: 0, limit: component.pagination?.limit || 5, + search: '', + priority: '', }; const initialData = component.tickets.data; @@ -203,8 +205,12 @@ export const TicketListPure: React.FC = ({ locale, accessTo initialValues={filters} onSubmit={handleFilter} onReset={handleReset} + variant="inline" labels={{ clickToSelect: data.labels.clickToSelect, + showMoreFilters: data.labels.showMoreFilters, + hideMoreFilters: data.labels.hideMoreFilters, + noActiveFilters: data.labels.noActiveFilters, }} /> diff --git a/packages/framework/src/modules/cms/models/blocks/ticket-list.model.ts b/packages/framework/src/modules/cms/models/blocks/ticket-list.model.ts index c95238686..77fb496a0 100644 --- a/packages/framework/src/modules/cms/models/blocks/ticket-list.model.ts +++ b/packages/framework/src/modules/cms/models/blocks/ticket-list.model.ts @@ -9,7 +9,9 @@ export class TicketListBlock extends Block.Block { table!: DataTable.DataTable; fieldMapping!: Mapping.Mapping; pagination?: Pagination.Pagination; - filters?: Filters.Filters; + filters?: Filters.Filters< + Ticket & { sort?: string; viewMode?: 'list' | 'grid'; search?: string; priority?: string } + >; noResults!: { title: string; description?: string; @@ -19,6 +21,9 @@ export class TicketListBlock extends Block.Block { yesterday: string; showMore: string; clickToSelect: string; + showMoreFilters?: string; + hideMoreFilters?: string; + noActiveFilters?: string; }; detailsUrl!: string; forms?: Link[]; @@ -37,8 +42,6 @@ export class Meta { subtitle!: string; table!: DataTable.DataTableMeta; fieldMapping!: Mapping.MappingMeta; - // pagination!: string; - // filters!: string; noResults!: { __id: string; title: string; @@ -50,7 +53,9 @@ export class Meta { yesterday: string; showMore: string; clickToSelect: string; + showMoreFilters?: string; + hideMoreFilters?: string; + noActiveFilters?: string; }; detailsUrl!: string; - // forms!: string; } diff --git a/packages/framework/src/modules/tickets/tickets.request.ts b/packages/framework/src/modules/tickets/tickets.request.ts index 4f50e8dc4..22efff5c9 100644 --- a/packages/framework/src/modules/tickets/tickets.request.ts +++ b/packages/framework/src/modules/tickets/tickets.request.ts @@ -18,5 +18,7 @@ export class GetTicketListQuery extends PaginationQuery { dateFrom?: Date; dateTo?: Date; sort?: string; + search?: string; + priority?: string; locale?: string; } diff --git a/packages/framework/src/utils/models/filters.ts b/packages/framework/src/utils/models/filters.ts index aa282d787..4f53a7994 100644 --- a/packages/framework/src/utils/models/filters.ts +++ b/packages/framework/src/utils/models/filters.ts @@ -34,6 +34,7 @@ export class FilterSelect extends Filter { export class FilterToggleGroup extends Filter { __typename!: 'FilterToggleGroup'; allowMultiple!: boolean; + isLabelHidden?: boolean; options!: { value: string; label: string; diff --git a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.ticket-list.mapper.ts b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.ticket-list.mapper.ts index 5d015a58c..936c9265c 100644 --- a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.ticket-list.mapper.ts +++ b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.ticket-list.mapper.ts @@ -124,13 +124,26 @@ const MOCK_TICKET_LIST_BLOCK_EN: CMS.Model.TicketListBlock.TicketListBlock = { __typename: 'FilterSelect', id: 'type', label: 'Case type', - allowMultiple: false, + allowMultiple: true, + isLeading: false, options: [ { label: 'Urgent', value: 'URGENT' }, { label: 'Standard', value: 'STANDARD' }, { label: 'Low Priority', value: 'LOW_PRIORITY' }, ], }, + { + __typename: 'FilterSelect', + id: 'priority', + label: 'Priority', + allowMultiple: false, + isLeading: false, + options: [ + { label: 'High', value: 'HIGH' }, + { label: 'Medium', value: 'MEDIUM' }, + { label: 'Low', value: 'LOW' }, + ], + }, { __typename: 'FilterDateRange', id: 'updatedAt', @@ -158,6 +171,9 @@ const MOCK_TICKET_LIST_BLOCK_EN: CMS.Model.TicketListBlock.TicketListBlock = { yesterday: 'Yesterday', showMore: 'Show more', clickToSelect: 'Click to select', + showMoreFilters: 'Show more filters', + hideMoreFilters: 'Hide more filters', + noActiveFilters: 'No active filters', }, detailsUrl: '/cases/{id}', }; @@ -237,12 +253,24 @@ const MOCK_TICKET_LIST_BLOCK_DE: CMS.Model.TicketListBlock.TicketListBlock = { close: 'Filter schließen', removeFilters: 'Filter entfernen ({active})', items: [ + { + __typename: 'FilterToggleGroup', + id: 'status', + label: 'Status', + allowMultiple: true, + isLeading: true, + options: [ + { label: 'Alle', value: 'ALL' }, + { label: 'In Bearbeitung', value: 'OPEN' }, + { label: 'Gelöst', value: 'CLOSED' }, + { label: 'Neue Antwort', value: 'IN_PROGRESS' }, + ], + }, { __typename: 'FilterSelect', id: 'sort', label: 'Sortieren nach', allowMultiple: false, - isLeading: true, options: [ { label: 'Thema aufsteigend', value: 'topic_ASC' }, { label: 'Thema absteigend', value: 'topic_DESC' }, @@ -254,24 +282,11 @@ const MOCK_TICKET_LIST_BLOCK_DE: CMS.Model.TicketListBlock.TicketListBlock = { { label: 'Aktualisiert absteigend', value: 'updatedAt_DESC' }, ], }, - { - __typename: 'FilterToggleGroup', - id: 'status', - label: 'Status', - allowMultiple: false, - isLeading: false, - options: [ - { label: 'Alle', value: 'ALL' }, - { label: 'In Bearbeitung', value: 'OPEN' }, - { label: 'Gelöst', value: 'CLOSED' }, - { label: 'Neue Antwort', value: 'IN_PROGRESS' }, - ], - }, { __typename: 'FilterSelect', id: 'topic', label: 'Thema', - allowMultiple: true, + allowMultiple: false, isLeading: false, options: [ { label: 'Alle', value: 'ALL' }, @@ -288,13 +303,26 @@ const MOCK_TICKET_LIST_BLOCK_DE: CMS.Model.TicketListBlock.TicketListBlock = { __typename: 'FilterSelect', id: 'type', label: 'Falltyp', - allowMultiple: false, + allowMultiple: true, + isLeading: false, options: [ { label: 'Dringend', value: 'URGENT' }, { label: 'Standard', value: 'STANDARD' }, { label: 'Niedrige Priorität', value: 'LOW_PRIORITY' }, ], }, + { + __typename: 'FilterSelect', + id: 'priority', + label: 'Priorität', + allowMultiple: false, + isLeading: false, + options: [ + { label: 'Hoch', value: 'HIGH' }, + { label: 'Mittel', value: 'MEDIUM' }, + { label: 'Niedrig', value: 'LOW' }, + ], + }, { __typename: 'FilterDateRange', id: 'updatedAt', @@ -322,6 +350,9 @@ const MOCK_TICKET_LIST_BLOCK_DE: CMS.Model.TicketListBlock.TicketListBlock = { yesterday: 'Gestern', showMore: 'Mehr anzeigen', clickToSelect: 'Klicken Sie, um auszuwählen', + showMoreFilters: 'Mehr Filter anzeigen', + hideMoreFilters: 'Weniger Filter anzeigen', + noActiveFilters: 'Keine aktiven Filter', }, detailsUrl: '/faelle/{id}', }; @@ -403,6 +434,19 @@ const MOCK_TICKET_LIST_BLOCK_PL: CMS.Model.TicketListBlock.TicketListBlock = { close: 'Zamknij filtry', removeFilters: 'Usuń filtry ({active})', items: [ + { + __typename: 'FilterToggleGroup', + id: 'status', + label: 'Status', + allowMultiple: true, + isLeading: true, + options: [ + { label: 'Wszystko', value: 'ALL' }, + { label: 'W rozpatrzeniu', value: 'OPEN' }, + { label: 'Rozwiązane', value: 'CLOSED' }, + { label: 'Nowa odpowiedź', value: 'IN_PROGRESS' }, + ], + }, { __typename: 'FilterSelect', id: 'sort', @@ -419,19 +463,6 @@ const MOCK_TICKET_LIST_BLOCK_PL: CMS.Model.TicketListBlock.TicketListBlock = { { label: 'Aktualizacja malejąco', value: 'updatedAt_DESC' }, ], }, - { - __typename: 'FilterToggleGroup', - id: 'status', - label: 'Status', - allowMultiple: false, - isLeading: true, - options: [ - { label: 'Wszystko', value: 'ALL' }, - { label: 'W rozpatrzeniu', value: 'OPEN' }, - { label: 'Rozwiązane', value: 'CLOSED' }, - { label: 'Nowa odpowiedź', value: 'IN_PROGRESS' }, - ], - }, { __typename: 'FilterSelect', id: 'topic', @@ -453,13 +484,26 @@ const MOCK_TICKET_LIST_BLOCK_PL: CMS.Model.TicketListBlock.TicketListBlock = { __typename: 'FilterSelect', id: 'type', label: 'Typ zgłoszenia', - allowMultiple: false, + allowMultiple: true, + isLeading: false, options: [ { label: 'Pilne', value: 'URGENT' }, { label: 'Standardowe', value: 'STANDARD' }, { label: 'Niski priorytet', value: 'LOW_PRIORITY' }, ], }, + { + __typename: 'FilterSelect', + id: 'priority', + label: 'Priorytet', + allowMultiple: false, + isLeading: false, + options: [ + { label: 'Wysoki', value: 'HIGH' }, + { label: 'Średni', value: 'MEDIUM' }, + { label: 'Niski', value: 'LOW' }, + ], + }, { __typename: 'FilterDateRange', id: 'updatedAt', @@ -487,6 +531,9 @@ const MOCK_TICKET_LIST_BLOCK_PL: CMS.Model.TicketListBlock.TicketListBlock = { yesterday: 'Wczoraj', showMore: 'Pokaż więcej', clickToSelect: 'Kliknij, aby wybrać', + showMoreFilters: 'Pokaż więcej filtrów', + hideMoreFilters: 'Ukryj więcej filtrów', + noActiveFilters: 'Brak aktywnych filtrów', }, detailsUrl: '/zgloszenia/{id}', }; diff --git a/packages/ui/src/components/Filters/FilterItem.tsx b/packages/ui/src/components/Filters/FilterItem.tsx index ec7b6bba9..45ad3940e 100644 --- a/packages/ui/src/components/Filters/FilterItem.tsx +++ b/packages/ui/src/components/Filters/FilterItem.tsx @@ -1,6 +1,6 @@ import { Field, FieldProps, FormikValues } from 'formik'; import { LayoutGrid, List, Search } from 'lucide-react'; -import React, { useRef } from 'react'; +import React, { useEffect, useMemo } from 'react'; import ScrollContainer from 'react-indiana-drag-scroll'; import { debounce } from 'throttle-debounce'; @@ -9,22 +9,27 @@ import { cn } from '@o2s/ui/lib/utils'; import { InputWithLabel } from '@o2s/ui/elements/input'; import { Label } from '@o2s/ui/elements/label'; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@o2s/ui/elements/select'; -import { ToggleGroup, ToggleGroupItem } from '@o2s/ui/elements/toggle-group'; +import { ToggleGroup, ToggleGroupItem, ToggleGroupWithLabel } from '@o2s/ui/elements/toggle-group'; import { FilterItemProps } from './Filters.types'; +const TEXT_FILTER_DEBOUNCE_MS = 1000; + export const FilterItem = ({ item, submitForm, setFieldValue, isLeading, labels, + isInlineVariant, }: Readonly>) => { - const allWasClickedRef = useRef(false); + const debouncedSubmit = useMemo(() => debounce(TEXT_FILTER_DEBOUNCE_MS, () => submitForm()), [submitForm]); - const onTextFilterChange = debounce(500, async () => { - await submitForm(); - }); + useEffect(() => { + return () => { + debouncedSubmit.cancel(); + }; + }, [debouncedSubmit]); switch (item.__typename) { case 'FilterToggleGroup': @@ -35,99 +40,85 @@ export const FilterItem = ({ (!field.value || field.value.length === 0) && item.options.some((option) => option.value === 'ALL') ? ['ALL'] - : field.value; + : field.value || []; + + const toggleGroupItems = item.options.map((option) => { + return ( + + {option.label} + + ); + }); + + const handleValueChange = async (value: string[]) => { + let newValue: string[]; + + const hadSelections = (field.value?.length ?? 0) > 0; + const includesAll = value.includes('ALL'); + + if (includesAll && hadSelections) { + newValue = []; + } else { + newValue = value.filter((v) => v !== 'ALL'); + } - const toggleGroup = ( - { - let newValue: string[]; - - if (allWasClickedRef.current) { - newValue = []; - allWasClickedRef.current = false; - } else { - newValue = value.filter((v) => v !== 'ALL'); - } - - await setFieldValue(field.name, newValue); - if (isLeading) { - await submitForm(); - } - }} + onValueChange={handleValueChange} + label={item.label} > - {item.options.map((option, index) => { - const isSelected = currentValue.includes(option.value); - const prevOption = item.options[index - 1]; - const nextOption = item.options[index + 1]; - const isPrevSelected = prevOption ? currentValue.includes(prevOption.value) : false; - const isNextSelected = nextOption ? currentValue.includes(nextOption.value) : false; - - return ( - { - allWasClickedRef.current = option.value === 'ALL'; - }} - > - {option.label} - - ); - })} - - ); - - return isLeading ? ( - toggleGroup - ) : ( - - {toggleGroup} - + + {toggleGroupItems} + + ); }} ) : ( {({ field }: FieldProps) => { - const toggleGroup = ( - ( + + {option.label} + + )); + + const handleValueChange = async (value: string) => { + const newValue = value === 'ALL' ? '' : value; + await setFieldValue(field.name, newValue); + if (isLeading || isInlineVariant) { + await submitForm(); + } + }; + + const currentValue = + !field.value && item.options.some((option) => option.value === 'ALL') ? 'ALL' : field.value; + + return ( + option.value === 'ALL') - ? 'ALL' - : field.value - } - onValueChange={async (value: string) => { - const newValue = value === 'ALL' ? '' : value; - await setFieldValue(field.name, newValue); - if (isLeading) { - await submitForm(); - } - }} + value={currentValue} + onValueChange={handleValueChange} + label={item.label} > - {item.options.map((option) => ( - - {option.label} - - ))} - - ); - - return isLeading ? ( - toggleGroup - ) : ( - - {toggleGroup} - + + {toggleGroupItems} + + ); }} @@ -144,7 +135,7 @@ export const FilterItem = ({ onValueChange={async (value) => { const newValue = value === ' ' ? '' : value; await setFieldValue(field.name, newValue); - if (isLeading) { + if (isLeading || isInlineVariant) { await submitForm(); } }} @@ -188,11 +179,8 @@ export const FilterItem = ({ behavior: 'prepend', }} onChange={async (e) => { - const newValue = e.target.value; - await setFieldValue(field.name, newValue); - if (isLeading) { - onTextFilterChange(); - } + await setFieldValue(field.name, e.target.value); + debouncedSubmit(); }} /> diff --git a/packages/ui/src/components/Filters/Filters.tsx b/packages/ui/src/components/Filters/Filters.tsx index e8fe7fa2b..1469f1b8d 100644 --- a/packages/ui/src/components/Filters/Filters.tsx +++ b/packages/ui/src/components/Filters/Filters.tsx @@ -1,19 +1,29 @@ import { Models } from '@o2s/framework/modules'; -import { Form, Formik, FormikValues } from 'formik'; +import { Form, Formik, FormikProps, FormikValues } from 'formik'; import { ListFilter, X } from 'lucide-react'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import ScrollContainer from 'react-indiana-drag-scroll'; import reactStringReplace from 'react-string-replace'; import { cn } from '@o2s/ui/lib/utils'; +import { Badge } from '@o2s/ui/elements/badge'; import { Button } from '@o2s/ui/elements/button'; +import { Separator } from '@o2s/ui/elements/separator'; import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@o2s/ui/elements/sheet'; +import { Typography } from '@o2s/ui/elements/typography'; import { FilterItem } from './FilterItem'; -import { FiltersProps } from './Filters.types'; +import { FilterLabels, FiltersProps } from './Filters.types'; import { useFiltersContext } from './FiltersContext'; +const SUPPORTED_FILTER_TYPES = ['FilterToggleGroup', 'FilterSelect', 'FilterText'] as const; +const SKIP_FILTER_KEYS = ['offset', 'limit', 'id', 'viewMode'] as const; + +const ANIMATION_BASE_DURATION_MS = 300; +const ANIMATION_STAGGER_IN_MS = 50; +const ANIMATION_STAGGER_OUT_MS = 30; + function separateLeadingItem(items: Models.Filters.FilterItem[]) { let leadingItem: Models.Filters.FilterItem | undefined; const filteredItems: Models.Filters.FilterItem[] = []; @@ -29,6 +39,293 @@ function separateLeadingItem(items: Models.Filters.FilterItem[]) { return { leadingItem, filteredItems }; } +function separateLeadingItems(items: Models.Filters.FilterItem[]) { + const leadingItems: Models.Filters.FilterItem[] = []; + const otherItems: Models.Filters.FilterItem[] = []; + + items.forEach((item) => { + if (!(SUPPORTED_FILTER_TYPES as readonly string[]).includes(item.__typename)) { + return; + } + + if ('isLeading' in item && item.isLeading === true) { + leadingItems.push(item); + } else { + otherItems.push(item); + } + }); + + return { leadingItems, otherItems }; +} + +interface ActiveFilterBadge { + id: string; + label: string; + value: string | string[]; + displayValue: string; +} + +function arraysEqual(a: T[], b: T[]): boolean { + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((val, index) => val === sortedB[index]); +} + +function getFilterResetValue(filterItem: Models.Filters.FilterItem | undefined): string | string[] { + if (!filterItem) return ''; + + switch (filterItem.__typename) { + case 'FilterToggleGroup': + return filterItem.allowMultiple ? [] : ''; + case 'FilterSelect': + case 'FilterText': + default: + return ''; + } +} + +function getActiveFilterBadges( + values: S, + initialFilters: S, + items: Models.Filters.FilterItem[], +): ActiveFilterBadge[] { + const badges: ActiveFilterBadge[] = []; + + for (const key in values) { + if (SKIP_FILTER_KEYS.includes(key as (typeof SKIP_FILTER_KEYS)[number])) { + continue; + } + + const currentValue = values[key]; + const initialValue = initialFilters[key]; + + if (currentValue === '' || currentValue === null || currentValue === undefined) { + continue; + } + + if (Array.isArray(currentValue) && currentValue.length === 0) { + continue; + } + + let hasChanged = false; + if (Array.isArray(currentValue) && Array.isArray(initialValue)) { + hasChanged = !arraysEqual(currentValue, initialValue); + } else { + hasChanged = currentValue !== initialValue; + } + + if (hasChanged) { + const item = items.find((i) => String(i.id) === key); + if (!item) continue; + + let displayValue = ''; + + if (item.__typename === 'FilterSelect' || item.__typename === 'FilterToggleGroup') { + if (Array.isArray(currentValue)) { + const optionLabels = currentValue + .map((val: string) => { + const option = item.options.find((opt) => opt.value === val); + return option ? option.label : val; + }) + .filter(Boolean); + displayValue = optionLabels.join(', '); + } else { + const option = item.options.find((opt) => opt.value === String(currentValue)); + displayValue = option ? option.label : String(currentValue); + } + } else if (item.__typename === 'FilterText') { + displayValue = String(currentValue); + } else { + displayValue = String(currentValue); + } + + const itemLabel = 'label' in item ? item.label : key; + + badges.push({ + id: key, + label: itemLabel, + value: currentValue, + displayValue, + }); + } + } + + return badges; +} + +interface InlineFiltersContentProps { + submitForm: FormikProps['submitForm']; + setFieldValue: FormikProps['setFieldValue']; + values: FormikProps['values']; + items: Models.Filters.FilterItem[]; + initialFilters: Record; + labels?: FilterLabels; + isExpanded: boolean; + setIsExpanded: (value: boolean) => void; + onResetFilters: (e: React.MouseEvent) => void; +} + +function InlineFiltersContent({ + submitForm, + setFieldValue, + values, + items, + initialFilters, + labels, + isExpanded, + setIsExpanded, + onResetFilters, +}: InlineFiltersContentProps) { + const { leadingItems, otherItems } = useMemo(() => separateLeadingItems(items), [items]); + const [shouldRender, setShouldRender] = useState(isExpanded); + const [isAnimatingIn, setIsAnimatingIn] = useState(false); + const [isAnimatingOut, setIsAnimatingOut] = useState(false); + const prevExpandedRef = React.useRef(isExpanded); + + React.useEffect(() => { + const prevExpanded = prevExpandedRef.current; + prevExpandedRef.current = isExpanded; + + if (isExpanded === prevExpanded) return; + + if (isExpanded) { + setIsAnimatingIn(true); + setShouldRender(true); + const animationDuration = ANIMATION_BASE_DURATION_MS + otherItems.length * ANIMATION_STAGGER_IN_MS; + const timer = setTimeout(() => setIsAnimatingIn(false), animationDuration); + return () => clearTimeout(timer); + } + + setIsAnimatingOut(true); + const animationDuration = ANIMATION_BASE_DURATION_MS + otherItems.length * ANIMATION_STAGGER_OUT_MS; + const timer = setTimeout(() => { + setShouldRender(false); + setIsAnimatingOut(false); + }, animationDuration); + return () => clearTimeout(timer); + }, [isExpanded, otherItems.length]); + + const activeFilterBadges = useMemo( + () => getActiveFilterBadges(values, initialFilters as S, items), + [values, initialFilters, items], + ); + + const handleRemoveFilter = async (filterId: string) => { + const filterItem = items.find((i) => String(i.id) === filterId); + const resetValue = initialFilters[filterId] ?? getFilterResetValue(filterItem); + + await setFieldValue(filterId, resetValue); + await submitForm(); + }; + + return ( +
+
+
+ {leadingItems.map((item) => ( +
+ + + +
+ ))} + {shouldRender && + otherItems.map((item, index) => ( +
+ +
+ ))} + {otherItems.length > 0 && ( + + )} +
+ + {activeFilterBadges.length > 0 && ( + <> + +
+ {activeFilterBadges.map((badge) => ( + + + {badge.label}: {badge.displayValue} + + + + ))} + {activeFilterBadges.length > 1 && ( + + )} +
+ + )} +
+
+ ); +} + export const Filters = ({ filters, initialValues, @@ -39,6 +336,7 @@ export const Filters = ({ labels, }: Readonly>) => { const [filtersOpen, setFiltersOpen] = useState(false); + const [isInlineExpanded, setIsInlineExpanded] = useState(false); const { activeFilters, countActiveFilters, initialFilters } = useFiltersContext(); if (!filters) { @@ -57,7 +355,6 @@ export const Filters = ({ onReset(); }; - // Inline variant: render filters directly without drawer if (variant === 'inline') { return (
@@ -69,45 +366,24 @@ export const Filters = ({ onSubmit(values); }} > - {({ submitForm, setFieldValue }) => ( -
-
- {/* Filters container - grid layout for desktop, full-width rows for mobile */} -
- {items.map((item) => ( -
- -
- ))} -
- {/* Action buttons - right-aligned on desktop, stretched on mobile */} -
- - -
-
-
+ {({ submitForm, setFieldValue, values }) => ( + + submitForm={submitForm} + setFieldValue={setFieldValue} + values={values} + items={items as Models.Filters.FilterItem[]} + initialFilters={initialFilters} + labels={labels} + isExpanded={isInlineExpanded} + setIsExpanded={setIsInlineExpanded} + onResetFilters={handleReset} + /> )}
); } - // Drawer variant: original implementation return (
@@ -131,6 +407,7 @@ export const Filters = ({ setFieldValue={setFieldValue} isLeading={true} labels={labels} + isInlineVariant={false} />
diff --git a/packages/ui/src/components/Filters/Filters.types.ts b/packages/ui/src/components/Filters/Filters.types.ts index 289be7646..0489949ac 100644 --- a/packages/ui/src/components/Filters/Filters.types.ts +++ b/packages/ui/src/components/Filters/Filters.types.ts @@ -1,6 +1,15 @@ import { Models } from '@o2s/framework/modules'; import { FormikErrors, FormikValues } from 'formik'; +export interface FilterLabels { + clickToSelect?: string; + showMoreFilters?: string; + hideMoreFilters?: string; + noActiveFilters?: string; + clearAllFilters?: string; + removeFilterAriaLabel?: string; +} + export interface FiltersProps { filters?: Models.Filters.Filters; initialValues: S; @@ -8,9 +17,7 @@ export interface FiltersProps { onReset: () => void; hasLeadingItem?: boolean; variant?: 'drawer' | 'inline'; - labels?: { - clickToSelect?: string; - }; + labels?: FilterLabels; } export interface FiltersSectionProps extends FiltersProps { @@ -26,7 +33,6 @@ export interface FilterItemProps { value: string[] | string | number | boolean | null, ) => Promise>; isLeading?: boolean; - labels?: { - clickToSelect?: string; - }; + labels?: FilterLabels; + isInlineVariant?: boolean; } diff --git a/packages/ui/src/elements/toggle-group.tsx b/packages/ui/src/elements/toggle-group.tsx index 68c6d5cfa..fb41d9a30 100644 --- a/packages/ui/src/elements/toggle-group.tsx +++ b/packages/ui/src/elements/toggle-group.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { cn } from '@o2s/ui/lib/utils'; +import { Label } from '@o2s/ui/elements/label'; import { toggleVariants } from '@o2s/ui/elements/toggle'; const ToggleGroupContext = React.createContext< @@ -14,18 +15,21 @@ const ToggleGroupContext = React.createContext< currentValue: undefined, }); -const toggleGroupVariants = cva('flex items-center justify-center gap-1', { - variants: { - variant: { - default: 'bg-transparent', - outline: 'bg-transparent', - solid: 'rounded-sm bg-muted p-1 gap-0', +const toggleGroupVariants = cva( + 'flex items-center justify-center gap-1 [&_[data-state=on]+[data-state=on]]:rounded-l-none [&_[data-state=on]:has(+[data-state=on])]:rounded-r-none', + { + variants: { + variant: { + default: 'bg-transparent', + outline: 'bg-transparent', + solid: 'rounded-md bg-muted/40 p-0.5 gap-0', + }, + }, + defaultVariants: { + variant: 'default', }, }, - defaultVariants: { - variant: 'default', - }, -}); +); type ToggleGroupProps = React.ComponentPropsWithoutRef & VariantProps & { ref?: React.Ref> }; @@ -96,4 +100,28 @@ const ToggleGroupItem = React.forwardRef { + return ( +
+ + + {children} + +
+ ); +}; + +export { ToggleGroup, ToggleGroupItem, ToggleGroupWithLabel }; diff --git a/packages/ui/src/globals.css b/packages/ui/src/globals.css index 07704f3ea..507f3ef82 100644 --- a/packages/ui/src/globals.css +++ b/packages/ui/src/globals.css @@ -138,6 +138,28 @@ } } +@keyframes filterFadeIn { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes filterFadeOut { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-8px); + } +} + @utility container { margin-inline: auto; padding-inline: 2rem;