From efec776c177efa0aff1148ecf3767a55a0f83cf4 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 14 May 2026 13:00:37 +0300 Subject: [PATCH 01/11] feat(filter): new TEDI-Ready component #530 --- skills/tedi-react/SKILL.md | 2 +- skills/tedi-react/references/components.md | 78 ++ skills/tedi-react/references/forms.md | 72 ++ .../form/filter/filter-group-context.ts | 11 + .../components/form/filter/filter-group.tsx | 180 ++++ .../components/form/filter/filter.module.scss | 205 +++++ .../components/form/filter/filter.spec.tsx | 292 +++++++ .../components/form/filter/filter.stories.tsx | 827 ++++++++++++++++++ src/tedi/components/form/filter/filter.tsx | 640 ++++++++++++++ src/tedi/components/form/filter/index.ts | 3 + src/tedi/components/form/search/search.tsx | 2 + .../overlays/dropdown/dropdown.module.scss | 2 +- src/tedi/index.ts | 1 + .../providers/label-provider/labels-map.ts | 14 + 14 files changed, 2327 insertions(+), 2 deletions(-) create mode 100644 src/tedi/components/form/filter/filter-group-context.ts create mode 100644 src/tedi/components/form/filter/filter-group.tsx create mode 100644 src/tedi/components/form/filter/filter.module.scss create mode 100644 src/tedi/components/form/filter/filter.spec.tsx create mode 100644 src/tedi/components/form/filter/filter.stories.tsx create mode 100644 src/tedi/components/form/filter/filter.tsx create mode 100644 src/tedi/components/form/filter/index.ts diff --git a/skills/tedi-react/SKILL.md b/skills/tedi-react/SKILL.md index 2cc89bbb1..e5c4bc1fd 100644 --- a/skills/tedi-react/SKILL.md +++ b/skills/tedi-react/SKILL.md @@ -154,7 +154,7 @@ const [email, setEmail] = useState(''); setAgreed(checked)} /> ``` -Form controls: `TextField`, `Select`, `TextArea`, `NumberField`, `Checkbox`, `Radio`, `ChoiceGroup`, `Search`, `DateField`, `FileUpload`, `FileDropzone`. +Form controls: `TextField`, `Select`, `TextArea`, `NumberField`, `Checkbox`, `Radio`, `ChoiceGroup`, `Search`, `DateField`, `Filter` (+ `FilterGroup`), `FileUpload`, `FileDropzone`. ## Theming diff --git a/skills/tedi-react/references/components.md b/skills/tedi-react/references/components.md index ea9b34867..5d28f129f 100644 --- a/skills/tedi-react/references/components.md +++ b/skills/tedi-react/references/components.md @@ -338,6 +338,84 @@ The ref shape mirrors TextField (`{ input, wrapper }`). In `'multiple'` mode the ``` +### Filter / FilterGroup +**Props:** `FilterProps`, `FilterGroupProps` | form + +Compact pill-shaped trigger used to refine result sets. Four modes — chosen at render time +by which props are present: + +- **Toggle** — no `options`, no `children`. Acts like a sticky checkbox. +- **Single-select dropdown** — pass `options`. Selecting commits the value and closes the panel. +- **Multi-select dropdown** — `options` + `multiselect`. Clicking does not close. Supports + `searchable`, `showSelectAll`, `showClear`. +- **Custom dropdown content** — pass `children` to embed any panel (date picker, radio group). + +```tsx +import { Filter, FilterGroup } from '@tedi-design-system/react/tedi'; + +// Toggle + + +// Single-select with "Label: Value" trigger + + +// Multi-select with search & "select all" + + +// Custom dropdown content — show clear action that resets consumer state + setPeriod('')}> + + +``` + +Wrap related filters in `FilterGroup` to coordinate selection: + +```tsx +// Single-select group (radio-like, role="radiogroup") + + + + + + +// Multi-select group (checkbox-like, role="group") + + + + + +// Visual-only group (no managed props — children stay independent) + + + + +``` + +Key props: +- `variant?: 'primary' | 'secondary'`, `size?: 'default' | 'large'` +- `prepend?: ReactNode`, `append?: ReactNode`, `hidePrependWhenSelected?: boolean` +- `appendTo?: 'body' | HTMLElement` — portal target for the dropdown +- `selectAllLabel?: string` (default `'Vali kõik'`), `clearLabel?: string` (default `'Tühjenda valik'`) + ### FileUpload **Props:** `FileUploadProps` | form - `id: string` (required), `name: string` (required) diff --git a/skills/tedi-react/references/forms.md b/skills/tedi-react/references/forms.md index 54cccc776..fd12d39a8 100644 --- a/skills/tedi-react/references/forms.md +++ b/skills/tedi-react/references/forms.md @@ -15,6 +15,7 @@ TEDI form controls support both **controlled** and **uncontrolled** modes, follo | ChoiceGroup | `ChoiceGroupValue` | Radio/checkbox groups, segmented variant | | Search | `string` | Search button, onSearch callback | | DateField | `Date \| Date[] \| DateRange` | Single/multiple/range, manual input, min/max, native picker, breakpoint-aware | +| Filter | `boolean \| string \| string[]` | Pill-shaped toggle / dropdown filter — single, multi-select, custom panel; pairs with `FilterGroup` | | FileUpload | `FileUploadFile[]` | Multi-file, validation, loading states | | FileDropzone | `FileUploadFile[]` | Drag-and-drop | @@ -177,6 +178,77 @@ const [date, setDate] = useState(); /> ``` +## Filter + +Compact pill-shaped trigger for refining result sets. Renders one of four modes depending on +which props are present: **toggle** (no `options`/`children`), **single-select dropdown** +(`options`), **multi-select dropdown** (`options` + `multiselect`), or **custom dropdown +content** (`children`). + +```tsx +import { Filter, FilterGroup } from '@tedi-design-system/react/tedi'; + +// Toggle + + +// Single-select — `preserveLabel` renders "Label: Selected value" + + +// Multi-select with searchable + select all + clear + + +// Custom dropdown content + setPeriod('')}> + + +``` + +Group filters with `FilterGroup` to coordinate selection — radio-like by default, checkbox-like +when `multiselect`: + +```tsx + + + + + + + + + + +``` + +A `FilterGroup` without any of `value` / `defaultValue` / `values` / `defaultValues` / +`onValueChange` / `onValuesChange` / `label` / `multiselect` is **unmanaged** — children +behave as standalone toggles and the wrapper exists only for visual grouping. + +Variants and customisation: +- `variant?: 'primary' | 'secondary'`, `size?: 'default' | 'large'` +- `prepend` / `append` for icons or badges. `hidePrependWhenSelected` (default `true`) swaps + the prepend slot for the check icon when the filter becomes selected. +- `appendTo: 'body' | HTMLElement` portals the dropdown out of the trigger's stacking context. +- Estonian copy by default: `selectAllLabel='Vali kõik'`, `clearLabel='Tühjenda valik'`. + ## Checkbox & Radio ```tsx diff --git a/src/tedi/components/form/filter/filter-group-context.ts b/src/tedi/components/form/filter/filter-group-context.ts new file mode 100644 index 000000000..dd383e46c --- /dev/null +++ b/src/tedi/components/form/filter/filter-group-context.ts @@ -0,0 +1,11 @@ +import { createContext } from 'react'; + +export interface FilterGroupContextValue { + isManaged: boolean; + multiselect: boolean; + disabled: boolean; + isSelected: (value: string) => boolean; + selectFilter: (value: string) => void; +} + +export const FilterGroupContext = createContext(null); diff --git a/src/tedi/components/form/filter/filter-group.tsx b/src/tedi/components/form/filter/filter-group.tsx new file mode 100644 index 000000000..d36c254b3 --- /dev/null +++ b/src/tedi/components/form/filter/filter-group.tsx @@ -0,0 +1,180 @@ +import cn from 'classnames'; +import React from 'react'; + +import styles from './filter.module.scss'; +import { FilterGroupContext, FilterGroupContextValue } from './filter-group-context'; + +interface FilterGroupCommonProps { + /** + * Accessible label for the group, exposed as `aria-label` on the container. + * + * Recommended whenever the group is managed (single- or multi-select) so screen readers + * announce the radio/group semantics with context (e.g. "Status, radio group"). Setting + * `label` also implicitly turns the group into managed mode. + */ + label?: string; + /** + * When `true`, every `` inside the group is disabled, regardless of their own + * `disabled` props. Useful for "this section isn't applicable yet" UX. + * + * @default false + */ + disabled?: boolean; + /** + * Extra class name appended to the group container `
`. Use this for spacing/layout; + * the per-filter radius and border-collapsing already comes from the group's own styles. + */ + className?: string; + /** + * `` children that participate in the group. Non-`` nodes are rendered + * verbatim but do not contribute to the managed selection state. + */ + children: React.ReactNode; +} + +interface FilterGroupSingleProps extends FilterGroupCommonProps { + /** + * When `false` or omitted, the group enforces single-select (radio-like) semantics — + * picking a child deselects the others; re-clicking the active one toggles it to `null`. + * + * @default false + */ + multiselect?: false; + /** + * **Controlled** selected value. The string identifier matches the `value` prop on the + * `` child you want highlighted, or `null` for "nothing selected". + * + * Pair with `onValueChange` to fully own the state. + */ + value?: string | null; + /** + * **Uncontrolled** initial selected value. Ignored once `value` is provided. + */ + defaultValue?: string | null; + /** + * Fires whenever the selected value changes — when a child is clicked, or when the + * currently-active child is re-clicked to toggle it back to `null`. + */ + onValueChange?: (value: string | null) => void; + values?: never; + defaultValues?: never; + onValuesChange?: never; +} + +interface FilterGroupMultiProps extends FilterGroupCommonProps { + /** + * Set to `true` to switch the group into multi-select (toggle-group) semantics — multiple + * children can be active at once and `aria-pressed` is used in place of `aria-checked`. + */ + multiselect: true; + /** + * **Controlled** selected values array. Each entry should match the `value` prop of one + * `` child. Order is preserved as the user toggles entries. + */ + values?: string[]; + /** + * **Uncontrolled** initial selected values array. Ignored once `values` is provided. + */ + defaultValues?: string[]; + /** + * Fires whenever the selected values array changes (any child toggled on or off). + */ + onValuesChange?: (values: string[]) => void; + value?: never; + defaultValue?: never; + onValueChange?: never; +} + +export type FilterGroupProps = FilterGroupSingleProps | FilterGroupMultiProps; + +type FilterGroupInternalProps = FilterGroupCommonProps & { + multiselect?: boolean; + value?: string | null; + defaultValue?: string | null; + onValueChange?: (value: string | null) => void; + values?: string[]; + defaultValues?: string[]; + onValuesChange?: (values: string[]) => void; +}; + +export const FilterGroup = (props: FilterGroupProps): JSX.Element => { + const { + label, + disabled = false, + className, + children, + multiselect = false, + value: controlledValue, + defaultValue, + onValueChange, + values: controlledValues, + defaultValues, + onValuesChange, + } = props as FilterGroupInternalProps; + + const isManaged = + controlledValue !== undefined || + defaultValue !== undefined || + onValueChange !== undefined || + controlledValues !== undefined || + defaultValues !== undefined || + onValuesChange !== undefined || + Boolean(label) || + multiselect; + + const [innerValue, setInnerValue] = React.useState(defaultValue ?? null); + const [innerValues, setInnerValues] = React.useState(defaultValues ?? []); + + const currentValue = controlledValue !== undefined ? controlledValue : innerValue; + const currentValues = controlledValues !== undefined ? controlledValues : innerValues; + + const selectFilter = React.useCallback( + (val: string) => { + if (multiselect) { + const next = currentValues.includes(val) ? currentValues.filter((v) => v !== val) : [...currentValues, val]; + if (controlledValues === undefined) { + setInnerValues(next); + } + onValuesChange?.(next); + } else { + const next = currentValue === val ? null : val; + if (controlledValue === undefined) { + setInnerValue(next); + } + onValueChange?.(next); + } + }, + [multiselect, currentValues, currentValue, controlledValues, controlledValue, onValuesChange, onValueChange] + ); + + const isSelected = React.useCallback( + (val: string): boolean => { + if (multiselect) return currentValues.includes(val); + return currentValue === val; + }, + [multiselect, currentValues, currentValue] + ); + + const context = React.useMemo( + () => ({ + isManaged, + multiselect, + disabled, + isSelected, + selectFilter, + }), + [isManaged, multiselect, disabled, isSelected, selectFilter] + ); + + const role = isManaged ? (multiselect ? 'group' : 'radiogroup') : undefined; + + return ( + +
+ {children} +
+
+ ); +}; + +FilterGroup.displayName = 'FilterGroup'; diff --git a/src/tedi/components/form/filter/filter.module.scss b/src/tedi/components/form/filter/filter.module.scss new file mode 100644 index 000000000..ff5d18245 --- /dev/null +++ b/src/tedi/components/form/filter/filter.module.scss @@ -0,0 +1,205 @@ +.tedi-filter { + --filter-bg: transparent; + --filter-text: inherit; + --filter-border: transparent; + --filter-border-width: var(--tedi-borders-01); + --filter-padding-x: var(--filter-default-padding-x); + --filter-radius: var(--form-checkbox-radio-card-radius); + + display: inline-flex; + + &__button { + all: unset; + box-sizing: border-box; + display: inline-flex; + align-items: center; + max-width: var(--button-width-max); + padding: 0 var(--filter-padding-x); + font-family: var(--family-default); + font-size: var(--body-regular-size); + font-weight: var(--body-regular-weight); + line-height: var(--body-regular-line-height); + color: var(--filter-text); + cursor: pointer; + background-color: var(--filter-bg); + border: var(--filter-border-width) solid var(--filter-border); + border-radius: var(--filter-radius); + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 1px var(--tedi-neutral-100), 0 0 0 3px var(--form-input-border-active); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + } + + &__text { + padding: calc(var(--filter-default-padding-y) - var(--filter-border-width)) var(--filter-default-inner-spacing); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__icon { + display: inline-flex; + padding: 0 var(--filter-default-inner-spacing-sm); + } + + &__prepend { + display: flex; + align-items: center; + padding: 0 var(--filter-default-inner-spacing-sm); + + &:empty { + display: none; + } + } + + &__append { + display: flex; + align-items: center; + padding: 0 var(--layout-grid-gutters-02); + + &:empty { + display: none; + } + } + + &__prepend--hidden { + display: none; + } + + &__count { + padding-left: var(--layout-grid-gutters-04); + } + + &--primary { + --filter-border-width: 0px; + --filter-bg: var(--filter-primary-default-background); + --filter-text: var(--filter-primary-default-text); + + .tedi-filter__button:hover, + .tedi-filter__button:active, + .tedi-filter__button[aria-expanded='true'] { + --filter-bg: var(--filter-primary-hover-background); + --filter-text: var(--filter-primary-hover-text); + } + + &.tedi-filter--selected { + --filter-bg: var(--filter-primary-selected-background); + --filter-text: var(--filter-primary-selected-text); + + .tedi-filter__button:hover, + .tedi-filter__button:active, + .tedi-filter__button[aria-expanded='true'] { + --filter-bg: var(--filter-primary-hover-background); + --filter-text: var(--filter-primary-hover-text); + } + } + } + + &--secondary { + --filter-bg: var(--filter-secondary-default-background); + --filter-text: var(--filter-secondary-default-text); + --filter-border: var(--filter-secondary-default-border); + + .tedi-filter__button:hover, + .tedi-filter__button:active, + .tedi-filter__button[aria-expanded='true'] { + --filter-bg: var(--filter-secondary-hover-background); + --filter-text: var(--filter-secondary-hover-text); + --filter-border: var(--filter-secondary-hover-border); + } + + &.tedi-filter--selected { + --filter-bg: var(--filter-secondary-selected-background); + --filter-text: var(--filter-secondary-selected-text); + --filter-border: var(--filter-secondary-selected-border); + --filter-border-width: var(--general-selected-border-width); + + // Selected + hover/active keeps the 2px selected border but swaps the surface to the + // secondary hover tint. + .tedi-filter__button:hover, + .tedi-filter__button:active, + .tedi-filter__button[aria-expanded='true'] { + --filter-bg: var(--filter-secondary-hover-background); + --filter-border: var(--filter-secondary-hover-border); + } + } + } + + &--large { + --filter-padding-x: var(--filter-lg-padding-x); + + .tedi-filter__text { + padding-top: calc(var(--filter-lg-padding-y) - var(--filter-border-width)); + padding-bottom: calc(var(--filter-lg-padding-y) - var(--filter-border-width)); + } + } +} + +.tedi-filter-group { + display: inline-flex; + + .tedi-filter { + --filter-radius: 0; + } + + .tedi-filter--secondary + .tedi-filter--secondary { + margin-left: calc(-1 * var(--tedi-borders-01)); + } + + .tedi-filter--primary:not(:last-child) .tedi-filter__button { + border-right: var(--tedi-borders-01) solid var(--filter-secondary-default-border); + + // On hover/active the button is filled with the hover bg — match the divider colour so + // it blends into the surface instead of cutting through it. + &:hover, + &:active, + &[aria-expanded='true'] { + border-right-color: var(--filter-primary-hover-background); + } + } + + .tedi-filter--selected, + .tedi-filter:focus-within { + z-index: 1; + } + + .tedi-filter--secondary.tedi-filter--selected + .tedi-filter--secondary, + .tedi-filter--secondary + .tedi-filter--secondary.tedi-filter--selected { + margin-left: calc(-1 * var(--general-selected-border-width)); + } + + .tedi-filter:first-child { + --filter-radius: var(--form-checkbox-radio-card-radius) 0 0 var(--form-checkbox-radio-card-radius); + } + + .tedi-filter:last-child { + --filter-radius: 0 var(--form-checkbox-radio-card-radius) var(--form-checkbox-radio-card-radius) 0; + } + + .tedi-filter:only-child { + --filter-radius: var(--form-checkbox-radio-card-radius); + } +} + +.tedi-filter-dropdown { + &__custom-content { + padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x); + } + + &__search { + padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x); + } + + &__clear { + display: flex; + justify-content: center; + padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x); + background: var(--dropdown-item-default-background); + } +} diff --git a/src/tedi/components/form/filter/filter.spec.tsx b/src/tedi/components/form/filter/filter.spec.tsx new file mode 100644 index 000000000..0ee7ba013 --- /dev/null +++ b/src/tedi/components/form/filter/filter.spec.tsx @@ -0,0 +1,292 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; + +import { Filter, FilterOption } from './filter'; +import { FilterGroup } from './filter-group'; + +import '@testing-library/jest-dom'; + +jest.mock('../../../providers/label-provider', () => { + const labels: Record = { + 'filter.clear-selection': 'Tühjenda valik', + 'filter.select-all': 'Vali kõik', + close: 'Close', + }; + return { + useLabels: () => ({ + getLabel: (key: string) => labels[key] ?? key, + }), + }; +}); + +const options: FilterOption[] = [ + { label: 'Option A', value: 'a' }, + { label: 'Option B', value: 'b' }, + { label: 'Option C', value: 'c', disabled: true }, +]; + +describe('Filter — toggle mode', () => { + it('renders the trigger with text', () => { + render(); + expect(screen.getByRole('button', { name: /active/i })).toBeInTheDocument(); + }); + + it('toggles selected state on click (uncontrolled)', async () => { + const user = userEvent.setup(); + render(); + const button = screen.getByRole('button', { name: /active/i }); + expect(button).toHaveAttribute('aria-pressed', 'false'); + await user.click(button); + expect(button).toHaveAttribute('aria-pressed', 'true'); + }); + + it('respects defaultSelected', () => { + render(); + expect(screen.getByRole('button', { name: /active/i })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('calls onSelectedChange when toggled', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + await user.click(screen.getByRole('button')); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it('honours controlled selected prop', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const { rerender } = render(); + const button = screen.getByRole('button'); + await user.click(button); + expect(button).toHaveAttribute('aria-pressed', 'false'); + rerender(); + expect(button).toHaveAttribute('aria-pressed', 'true'); + }); + + it('disables the trigger when disabled', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); +}); + +describe('Filter — single-select dropdown', () => { + it('opens the menu on trigger click and renders an item per option', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /teenus/i })); + const menu = await screen.findByRole('menu'); + expect(within(menu).getAllByRole('menuitem')).toHaveLength(3); + }); + + it('selecting an option updates the trigger label and closes the menu', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /teenus/i })); + await user.click(screen.getByRole('menuitem', { name: 'Option A' })); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveAccessibleName('Option A'); + }); + + it('preserveLabel keeps the filter label as a prefix', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button')); + await user.click(screen.getByRole('menuitem', { name: 'Option A' })); + expect(screen.getByRole('button')).toHaveAccessibleName('Teenus: Option A'); + }); + + it('calls onSelectedValueChange with the new value', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + await user.click(screen.getByRole('button')); + await user.click(screen.getByRole('menuitem', { name: 'Option B' })); + expect(onChange).toHaveBeenCalledWith('b'); + }); + + it('does not commit a value when a disabled option is clicked', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + await user.click(screen.getByRole('button')); + await user.click(screen.getByRole('menuitem', { name: 'Option C' })); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('clear button resets the selection', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render( + + ); + await user.click(screen.getByRole('button')); + await user.click(screen.getByRole('button', { name: /tühjenda valik/i })); + expect(onChange).toHaveBeenCalledWith(''); + }); +}); + +describe('Filter — multi-select dropdown', () => { + it('opens with one checkbox per option and toggles them independently', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + await user.click(screen.getByRole('button')); + expect(await screen.findByRole('menu')).toBeInTheDocument(); + await user.click(screen.getByRole('checkbox', { name: 'Option A' })); + expect(onChange).toHaveBeenLastCalledWith(['a']); + await user.click(screen.getByRole('checkbox', { name: 'Option B' })); + expect(onChange).toHaveBeenLastCalledWith(['a', 'b']); + // The menu stays open across multi-select clicks. + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); + + it('shows a count badge that mirrors the number of selected options', async () => { + const user = userEvent.setup(); + render(); + expect(screen.getByText('2')).toBeInTheDocument(); + await user.click(screen.getByRole('button')); + await user.click(screen.getByRole('checkbox', { name: 'Option A' })); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('select-all toggles all enabled filtered options', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + await user.click(screen.getByRole('button')); + await user.click(screen.getByRole('checkbox', { name: /vali kõik/i })); + expect(onChange).toHaveBeenLastCalledWith(['a', 'b']); + }); + + it('search input filters the visible options', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button')); + await user.type(screen.getByRole('searchbox'), 'b'); + expect(screen.queryByRole('checkbox', { name: 'Option A' })).not.toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'Option B' })).toBeInTheDocument(); + }); + + it('clear empties the selection', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render( + + ); + await user.click(screen.getByRole('button')); + await user.click(screen.getByRole('button', { name: /tühjenda valik/i })); + expect(onChange).toHaveBeenLastCalledWith([]); + }); +}); + +describe('Filter — custom dropdown content', () => { + it('renders children and an optional clear action that fires onClear', async () => { + const user = userEvent.setup(); + const onClear = jest.fn(); + render( + +
custom panel
+
+ ); + await user.click(screen.getByRole('button', { name: /period/i })); + expect(screen.getByText('custom panel')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /tühjenda valik/i })); + expect(onClear).toHaveBeenCalled(); + }); +}); + +describe('FilterGroup — single-select (radiogroup)', () => { + function Harness() { + const [value, setValue] = useState(null); + return ( + + + + + + ); + } + + it('uses radiogroup semantics and radio roles on each filter', () => { + render(); + expect(screen.getByRole('radiogroup', { name: 'Status' })).toBeInTheDocument(); + expect(screen.getAllByRole('radio')).toHaveLength(3); + }); + + it('selecting one filter deselects the others', async () => { + const user = userEvent.setup(); + render(); + const radios = screen.getAllByRole('radio'); + await user.click(radios[1]); + expect(radios[0]).toHaveAttribute('aria-checked', 'false'); + expect(radios[1]).toHaveAttribute('aria-checked', 'true'); + expect(radios[2]).toHaveAttribute('aria-checked', 'false'); + }); + + it('clicking the selected filter toggles it back to null', async () => { + const user = userEvent.setup(); + render(); + const radios = screen.getAllByRole('radio'); + await user.click(radios[1]); + await user.click(radios[1]); + expect(radios[1]).toHaveAttribute('aria-checked', 'false'); + }); +}); + +describe('FilterGroup — multi-select', () => { + function Harness() { + const [values, setValues] = useState([]); + return ( + + + + + + ); + } + + it('exposes group role and aria-pressed on each filter', () => { + render(); + expect(screen.getByRole('group', { name: 'Tags' })).toBeInTheDocument(); + const buttons = screen.getAllByRole('button'); + buttons.forEach((btn) => { + expect(btn).toHaveAttribute('aria-pressed', 'false'); + }); + }); + + it('toggles filters independently', async () => { + const user = userEvent.setup(); + render(); + const buttons = screen.getAllByRole('button'); + await user.click(buttons[0]); + await user.click(buttons[1]); + expect(buttons[0]).toHaveAttribute('aria-pressed', 'true'); + expect(buttons[1]).toHaveAttribute('aria-pressed', 'true'); + expect(buttons[2]).toHaveAttribute('aria-pressed', 'false'); + }); +}); + +describe('FilterGroup — unmanaged', () => { + it('does not add group role when no managed props are passed', () => { + render( + + + + + ); + expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument(); + expect(screen.queryByRole('group')).not.toBeInTheDocument(); + const buttons = screen.getAllByRole('button'); + expect(buttons[0]).toHaveAttribute('aria-pressed', 'true'); + expect(buttons[1]).toHaveAttribute('aria-pressed', 'false'); + }); +}); diff --git a/src/tedi/components/form/filter/filter.stories.tsx b/src/tedi/components/form/filter/filter.stories.tsx new file mode 100644 index 000000000..dad4e3f6b --- /dev/null +++ b/src/tedi/components/form/filter/filter.stories.tsx @@ -0,0 +1,827 @@ +import { Meta, StoryFn, StoryObj } from '@storybook/react'; +import { useMemo, useState } from 'react'; + +import { Icon } from '../../base/icon/icon'; +import { Text } from '../../base/typography/text/text'; +import Button from '../../buttons/button/button'; +import { Col, Row } from '../../layout/grid'; +import { VerticalSpacing } from '../../layout/vertical-spacing'; +import Separator from '../../misc/separator/separator'; +import { StatusBadge } from '../../tags/status-badge/status-badge'; +import { StatusIndicator } from '../../tags/status-indicator/status-indicator'; +import { Tag } from '../../tags/tag/tag'; +import { ChoiceGroup } from '../choice-group'; +import { Filter, FilterOption, FilterProps } from './filter'; +import { FilterGroup } from './filter-group'; + +/** + * Figma ↗ + */ + +const meta: Meta = { + component: Filter, + title: 'TEDI-Ready/Components/Form/Filter', + parameters: { + status: { + type: [{ name: 'breakpointSupport', url: '?path=/docs/helpers-usebreakpointprops--usebreakpointprops' }], + }, + controls: { exclude: ['sm', 'md', 'lg', 'xl', 'xxl'] }, + design: { + type: 'figma', + url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.45.70?node-id=6562-159554&m=dev', + }, + }, +}; +export default meta; +type Story = StoryObj; + +const teenusOptions: FilterOption[] = [ + { label: 'Optometristi vastuvõtt', value: '1' }, + { label: 'Silmaarsti vastuvõtt', value: '2' }, + { label: 'Hambaarsti vastuvõtt', value: '3' }, +]; + +const raviasutusOptions: FilterOption[] = [ + { label: 'Fertilitas', value: '1' }, + { label: 'Ida-Tallinna Keskhaigla', value: '2' }, + { label: 'Lääne-Tallinna Keskhaigla', value: '3' }, + { label: 'Põhja-Eesti Regionaalhaigla', value: '4' }, + { label: 'Tallinna Lastehaigla', value: '5' }, + { label: 'Tartu Ülikooli Kliinikum', value: '6' }, +]; + +const Template: StoryFn = (args) => ; + +export const Default: Story = { + render: Template, + args: { text: 'Teenused' }, +}; + +const sizeArray: FilterProps['size'][] = ['default', 'large']; + +export const Size: Story = { + render: () => ( +
+ {sizeArray.map((value, key) => ( + + + {value ? value.charAt(0).toUpperCase() + value.slice(1) : ''} + + + + + + + + + ))} +
+ ), +}; + +/** + * Single value filters include boolean toggles (separate and grouped) and single-select dropdown + * filters. The grouped variant shares a `FilterGroup` to coordinate selection. + */ +export const SingleValueFilter: Story = { + render: () => ( + + + Separate +
+ + + + +
+
+ + + + +
+
+ } + /> + } /> + } /> + } + /> +
+
+ + + Grouped +
+ + + + + + + + +
+
+ + + + + + + + +
+
+ + + + + + +
+
+ + + Dropdown label + value +
+ +
+
+ +
+
+ + + Dropdown value +
+ + +
+
+ + +
+
+
+ ), +}; + +/** + * Multi value filters open a dropdown with checkboxes. Supports search, "Select all", and + * "Clear selection" out of the box. + */ +export const MultiValueFilter: Story = { + render: () => ( + +
+ +
+
+ +
+
+ ), +}; + +export const CustomizeContent: Story = { + render: () => ( + + + Prepend hidden when selected (default) + + + + } + /> + + + + + + + + } + /> + + + + + + + + 5} + /> + 7} + /> + + + + + + + 5} + /> + 7} + /> + + + + + + + Prepend visible when selected + + + + 5} + /> + 7} + /> + + + + + + + 5} + /> + 7} + /> + + + + + + + Append + + + + 5} + /> + 7} + /> + + + + + + + 5} + /> + 7} + /> + + + + + + + Append with dropdown + 7} + /> + + + + Prepend icon with append and dropdown + } + append={7} + /> + + + ), +}; + +export const States: Story = { + parameters: { + pseudo: { + hover: '.pseudo-hover button', + active: '.pseudo-active button', + focusVisible: '.pseudo-focus button', + }, + }, + render: () => { + const stateRows: { label: string; id: string; selected: boolean }[] = [ + { label: 'Default', id: 'default', selected: false }, + { label: 'Hover', id: 'hover', selected: false }, + { label: 'Active', id: 'active', selected: false }, + { label: 'Focus', id: 'focus', selected: false }, + { label: 'Selected', id: 'selected', selected: true }, + ]; + const stateOptions: FilterOption[] = [ + { label: 'Optometristi vastuvõtt', value: '1' }, + { label: 'Silmaarsti vastuvõtt', value: '2' }, + { label: 'Hambaarsti vastuvõtt', value: '3' }, + ]; + + return ( + + + + State + + + Primary + + + Primary multiselect + + + Secondary + + + Secondary multiselect + + + Large + + + {stateRows.map((row) => ( + + + {row.label} + + + + + + + + + + + + + + + + + + ))} + + ); + }, +}; + +export const CustomDropdownContent: Story = { + render: function CustomDropdownContentStory() { + const [selectedPeriod, setSelectedPeriod] = useState(''); + const periods = useMemo( + () => [ + { value: 'day', label: 'Päev' }, + { value: 'week', label: 'Nädal' }, + { value: 'month', label: 'Kuu' }, + { value: 'year', label: 'Aasta' }, + ], + [] + ); + const label = periods.find((p) => p.value === selectedPeriod)?.label ?? 'Periood'; + + return ( +
+ setSelectedPeriod('')}> + ({ id: p.value, label: p.label, value: p.value }))} + value={selectedPeriod} + onChange={(value) => setSelectedPeriod(String(value ?? ''))} + /> + +
+ ); + }, +}; + +type TagEntry = { key: string; text: string; remove: () => void }; + +const TagList = ({ tags }: { tags: TagEntry[] }) => + tags.length === 0 ? null : ( +
+ {tags.map((tag) => ( + + {tag.text} + + ))} +
+ ); + +/** + * Realistic page-level examples combining toggle filters, single- and multi-select dropdowns, + * managed `FilterGroup`s, tag chips that mirror the selected state, and a global clear action. + */ +export const Examples: Story = { + render: function ExamplesStory() { + const [vastuvotud, setVastuvotud] = useState(true); + const [analuusid, setAnaluusid] = useState(true); + const [uuringud, setUuringud] = useState(false); + const [uuring, setUuring] = useState(''); + const [raviasutus, setRaviasutus] = useState(['3', '4']); + const [teenus, setTeenus] = useState(''); + const [aegAlates, setAegAlates] = useState(''); + + const [typeAndmed, setTypeAndmed] = useState('all'); + const [teenusAndmed, setTeenusAndmed] = useState(''); + const [raviasutusAndmed, setRaviasutusAndmed] = useState([]); + + const [category, setCategory] = useState(['vastuvotud', 'analuusid']); + const [teenusDoc, setTeenusDoc] = useState(''); + + const [typePrimary, setTypePrimary] = useState('all'); + const [uuringPrimary, setUuringPrimary] = useState(''); + + const uuringOptions: FilterOption[] = useMemo( + () => [ + { label: 'Vereanalüüs', value: '1' }, + { label: 'Röntgen', value: '2' }, + { label: 'Ultraheli', value: '3' }, + { label: 'MRT', value: '4' }, + ], + [] + ); + const aegAlatesOptions: FilterOption[] = useMemo( + () => [ + { label: 'Viimane nädal', value: '1' }, + { label: 'Viimane kuu', value: '2' }, + { label: 'Viimane aasta', value: '3' }, + ], + [] + ); + const typeOptions: FilterOption[] = useMemo( + () => [ + { label: 'Kõik', value: 'all' }, + { label: 'Aktiivsed', value: 'active' }, + { label: 'Lõpetatud', value: 'done' }, + ], + [] + ); + const categoryOptions: FilterOption[] = useMemo( + () => [ + { label: 'Vastuvõtud', value: 'vastuvotud' }, + { label: 'Analüüsid', value: 'analuusid' }, + { label: 'Uuringud', value: 'uuringud' }, + { label: 'Vaktsineerimised', value: 'vaktsineerimised' }, + ], + [] + ); + + const labelOf = (options: FilterOption[], value: string) => options.find((o) => o.value === value)?.label ?? value; + + const section1Tags = useMemo(() => { + const t: TagEntry[] = []; + if (vastuvotud) t.push({ key: 'vastuvotud', text: 'Vastuvõtud', remove: () => setVastuvotud(false) }); + if (analuusid) t.push({ key: 'analuusid', text: 'Analüüsid', remove: () => setAnaluusid(false) }); + if (uuringud) t.push({ key: 'uuringud', text: 'Uuringud', remove: () => setUuringud(false) }); + if (uuring) { + t.push({ + key: `uuring-${uuring}`, + text: `Uuring: ${labelOf(uuringOptions, uuring)}`, + remove: () => setUuring(''), + }); + } + raviasutus.forEach((v) => { + t.push({ + key: `raviasutus-${v}`, + text: `Raviasutus: ${labelOf(raviasutusOptions, v)}`, + remove: () => setRaviasutus(raviasutus.filter((x) => x !== v)), + }); + }); + if (teenus) { + t.push({ + key: `teenus-${teenus}`, + text: `Teenus: ${labelOf(teenusOptions, teenus)}`, + remove: () => setTeenus(''), + }); + } + if (aegAlates) { + t.push({ + key: `aeg-${aegAlates}`, + text: `Aeg alates: ${labelOf(aegAlatesOptions, aegAlates)}`, + remove: () => setAegAlates(''), + }); + } + return t; + }, [vastuvotud, analuusid, uuringud, uuring, raviasutus, teenus, aegAlates, uuringOptions, aegAlatesOptions]); + + const section2Tags = useMemo(() => { + const t: TagEntry[] = []; + if (typeAndmed) { + t.push({ + key: `type-andmed-${typeAndmed}`, + text: `Tüüp: ${labelOf(typeOptions, typeAndmed)}`, + remove: () => setTypeAndmed(null), + }); + } + if (teenusAndmed) { + t.push({ + key: `teenus-andmed-${teenusAndmed}`, + text: `Teenus: ${labelOf(teenusOptions, teenusAndmed)}`, + remove: () => setTeenusAndmed(''), + }); + } + raviasutusAndmed.forEach((v) => { + t.push({ + key: `raviasutus-andmed-${v}`, + text: `Raviasutus: ${labelOf(raviasutusOptions, v)}`, + remove: () => setRaviasutusAndmed(raviasutusAndmed.filter((x) => x !== v)), + }); + }); + return t; + }, [typeAndmed, teenusAndmed, raviasutusAndmed, typeOptions]); + + const section3Tags = useMemo(() => { + const t: TagEntry[] = []; + category.forEach((v) => { + t.push({ + key: `category-${v}`, + text: `Kategooria: ${labelOf(categoryOptions, v)}`, + remove: () => setCategory(category.filter((x) => x !== v)), + }); + }); + if (teenusDoc) { + t.push({ + key: `teenus-doc-${teenusDoc}`, + text: `Teenus: ${labelOf(teenusOptions, teenusDoc)}`, + remove: () => setTeenusDoc(''), + }); + } + return t; + }, [category, teenusDoc, categoryOptions]); + + const section4Tags = useMemo(() => { + const t: TagEntry[] = []; + if (typePrimary) { + t.push({ + key: `type-primary-${typePrimary}`, + text: `Tüüp: ${labelOf(typeOptions, typePrimary)}`, + remove: () => setTypePrimary(null), + }); + } + if (uuringPrimary) { + t.push({ + key: `uuring-primary-${uuringPrimary}`, + text: `Uuring: ${labelOf(uuringOptions, uuringPrimary)}`, + remove: () => setUuringPrimary(''), + }); + } + return t; + }, [typePrimary, uuringPrimary, typeOptions, uuringOptions]); + + const clearAll = () => { + setVastuvotud(false); + setAnaluusid(false); + setUuringud(false); + setUuring(''); + setRaviasutus([]); + setTeenus(''); + setAegAlates(''); + }; + + return ( + + + Taotlused + +
+ + + + + + + + + + +
+ + {`${64 - section1Tags.length} tulemust`} + + + + {/* — Section 2: Andmed (single-select FilterGroup) */} + + Andmed + +
+ + + + + + + + +
+ + + + + {/* — Section 3: Menetlusdokumendid (multi-select FilterGroup) */} + + Menetlusdokumendid + +
+ + + + + + + + +
+ + + + + {/* — Section 4: Taotlused (primary variant) */} + + Taotlused (primary) + +
+ + + + + + + +
+ +
+ ); + }, +}; diff --git a/src/tedi/components/form/filter/filter.tsx b/src/tedi/components/form/filter/filter.tsx new file mode 100644 index 000000000..57bdfd437 --- /dev/null +++ b/src/tedi/components/form/filter/filter.tsx @@ -0,0 +1,640 @@ +import cn from 'classnames'; +import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; + +import { BreakpointSupport, useBreakpointProps } from '../../../helpers'; +import { useLabels } from '../../../providers/label-provider'; +import { Icon } from '../../base/icon/icon'; +import Button from '../../buttons/button/button'; +import { Dropdown } from '../../overlays/dropdown/dropdown'; +import { StatusBadge } from '../../tags/status-badge/status-badge'; +import { Checkbox } from '../checkbox/checkbox'; +import { Search } from '../search/search'; +import styles from './filter.module.scss'; +import { FilterGroupContext } from './filter-group-context'; + +export type FilterVariant = 'primary' | 'secondary'; +export type FilterSize = 'default' | 'large'; + +export interface FilterOption { + /** Display label of the option. */ + label: string; + /** Stable identifier returned via selection callbacks. */ + value: string; + /** Whether the option cannot be selected. */ + disabled?: boolean; +} + +type FilterBreakpointProps = { + /** + * Visual variant of the filter. + * @default primary + */ + variant?: FilterVariant; + /** + * Visual size of the filter. + * @default default + */ + size?: FilterSize; +}; + +export interface FilterProps extends BreakpointSupport { + /** + * Text shown on the trigger button. Acts as the accessible name when no + * dropdown options are selected; in single-select mode this is replaced by the + * selected option's label (or prefixed with it when `preserveLabel` is on). + */ + text: string; + /** + * Stable identifier used when the filter participates in a managed ``. + * + * The group reads this value to know which child is selected and writes it back + * via its `onValueChange` / `onValuesChange` handlers. Unused outside of a managed + * group context. + */ + value?: string; + /** + * When `true`, the trigger button is non-interactive and rendered with a muted style. + * + * If the filter lives inside a ``, the group's disabled flag + * also propagates here — there's no need to set both. + * + * @default false + */ + disabled?: boolean; + /** + * Extra class name appended to the root wrapper `
` (not the inner ` + ); + + if (!hasDropdown) { + return
{triggerButton}
; + } + + const selectAllIndex = isMultiSelect && showSelectAll ? 0 : null; + const optionsOffset = selectAllIndex !== null ? 1 : 0; + + const renderOptions = () => + filteredOptions.map((option, i) => { + const optionSelected = isMultiSelect ? multiValues.includes(option.value) : singleValue === option.value; + const index = i + optionsOffset; + + if (isMultiSelect) { + return ( + + commitMulti(option.value)} + name={`${baseId}-options`} + /> + + ); + } + + return ( + commitSingle(option.value)} + > + {option.label} + + ); + }); + + return ( +
+ + {triggerButton} + + {hasCustomContent ? ( + <> +
{children}
+ {showClear && ( + <> + +
+ +
+ + )} + + ) : ( + <> + {searchable && ( + <> +
+ +
+ + + )} + {selectAllIndex !== null && ( + <> + + + + + + )} + {renderOptions()} + {showClear && ( + <> + +
+ +
+ + )} + + )} +
+
+
+ ); +}); + +Filter.displayName = 'Filter'; + +export default Filter; diff --git a/src/tedi/components/form/filter/index.ts b/src/tedi/components/form/filter/index.ts new file mode 100644 index 000000000..595cd6acf --- /dev/null +++ b/src/tedi/components/form/filter/index.ts @@ -0,0 +1,3 @@ +export * from './filter'; +export * from './filter-group'; +export * from './filter-group-context'; diff --git a/src/tedi/components/form/search/search.tsx b/src/tedi/components/form/search/search.tsx index 4b9255993..55500f3f0 100644 --- a/src/tedi/components/form/search/search.tsx +++ b/src/tedi/components/form/search/search.tsx @@ -38,6 +38,7 @@ export const Search = forwardRef( button, ariaLabel, className, + input, ...rest }, ref @@ -61,6 +62,7 @@ export const Search = forwardRef( isClearable, onKeyDown: handleKeyDown, onChange, + input: { role: 'searchbox' as const, ...input }, ...(button ? {} : { icon: searchIcon }), }; diff --git a/src/tedi/components/overlays/dropdown/dropdown.module.scss b/src/tedi/components/overlays/dropdown/dropdown.module.scss index 3e49dabd5..a6ef038ab 100644 --- a/src/tedi/components/overlays/dropdown/dropdown.module.scss +++ b/src/tedi/components/overlays/dropdown/dropdown.module.scss @@ -4,7 +4,7 @@ z-index: var(--z-index-dropdown); display: flex; flex-direction: column; - width: var(--dropdown-min-width, 10rem); + width: var(--dropdown-min-width, max-content); pointer-events: none; background-color: var(--dropdown-item-default-background); border: 1px solid var(--card-border-primary); diff --git a/src/tedi/index.ts b/src/tedi/index.ts index 0fc6259eb..4aee5d06b 100644 --- a/src/tedi/index.ts +++ b/src/tedi/index.ts @@ -43,6 +43,7 @@ export * from './components/form/select/select'; export * from './components/form/checkbox/checkbox'; export * from './components/form/slider/slider'; export * from './components/form/date-field/date-field'; +export * from './components/form/filter'; export * from './components/form/input-group'; export * from './components/form/field/field'; export * from './components/overlays/tooltip'; diff --git a/src/tedi/providers/label-provider/labels-map.ts b/src/tedi/providers/label-provider/labels-map.ts index ef61448c8..9fd1238e3 100644 --- a/src/tedi/providers/label-provider/labels-map.ts +++ b/src/tedi/providers/label-provider/labels-map.ts @@ -114,6 +114,20 @@ export const labelsMap = validateDefaultLabels({ en: 'Clear', ru: 'Очистить', }, + 'filter.clear-selection': { + description: 'Clear-selection action shown in the Filter dropdown', + components: ['Filter'], + et: 'Tühjenda valik', + en: 'Clear selection', + ru: 'Очистить выбор', + }, + 'filter.select-all': { + description: 'Select-all checkbox shown in the multi-select Filter dropdown', + components: ['Filter'], + et: 'Vali kõik', + en: 'Select all', + ru: 'Выбрать все', + }, search: { description: 'For searching', components: ['TableFilter'], From e81809924012a17abd13c1023d8c0b21185a45e3 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 14 May 2026 13:30:01 +0300 Subject: [PATCH 02/11] feat(filter): update stories, move to another folder #530 --- .../content/calendar/calendar.module.scss | 12 ++++ .../components/content/calendar/calendar.tsx | 17 ++++- .../filter/filter-group-context.ts | 0 .../{form => filter}/filter/filter-group.tsx | 0 .../filter/filter.module.scss | 0 .../{form => filter}/filter/filter.spec.tsx | 0 .../filter/filter.stories.tsx | 63 ++++++++++++++++++- .../{form => filter}/filter/filter.tsx | 4 +- .../{form => filter}/filter/index.ts | 0 src/tedi/index.ts | 2 +- 10 files changed, 90 insertions(+), 8 deletions(-) rename src/tedi/components/{form => filter}/filter/filter-group-context.ts (100%) rename src/tedi/components/{form => filter}/filter/filter-group.tsx (100%) rename src/tedi/components/{form => filter}/filter/filter.module.scss (100%) rename src/tedi/components/{form => filter}/filter/filter.spec.tsx (100%) rename src/tedi/components/{form => filter}/filter/filter.stories.tsx (92%) rename src/tedi/components/{form => filter}/filter/filter.tsx (99%) rename src/tedi/components/{form => filter}/filter/index.ts (100%) diff --git a/src/tedi/components/content/calendar/calendar.module.scss b/src/tedi/components/content/calendar/calendar.module.scss index 01be53056..12487ca03 100644 --- a/src/tedi/components/content/calendar/calendar.module.scss +++ b/src/tedi/components/content/calendar/calendar.module.scss @@ -16,6 +16,12 @@ border: var(--tedi-borders-01) solid var(--card-border-primary); border-radius: var(--card-radius-rounded); + &--borderless { + background: none; + border: none; + border-radius: 0; + } + table { width: 100%; border-spacing: 0; @@ -198,6 +204,12 @@ max-width: 20rem; border: var(--tedi-borders-01) solid var(--card-border-primary); border-radius: var(--card-radius-rounded); + + &.tedi-calendar--borderless { + background: none; + border: none; + border-radius: 0; + } } .tedi-calendar__picker-grid-header { diff --git a/src/tedi/components/content/calendar/calendar.tsx b/src/tedi/components/content/calendar/calendar.tsx index c6efadb94..5d13b4917 100644 --- a/src/tedi/components/content/calendar/calendar.tsx +++ b/src/tedi/components/content/calendar/calendar.tsx @@ -115,6 +115,14 @@ export interface CalendarProps extends Omit { + const borderlessClass = !bordered ? styles['tedi-calendar--borderless'] : undefined; + const containerClassName = classNames(borderlessClass, className); const isAvailable = (date: Date) => { if (!availableDays) return true; @@ -202,7 +213,7 @@ export const Calendar = ({ setView('months'); } }} - className={className} + className={containerClassName} /> )} @@ -221,7 +232,7 @@ export const Calendar = ({ setView('days'); } }} - className={className} + className={containerClassName} /> )} @@ -251,7 +262,7 @@ export const Calendar = ({ }} footer={footer} classNames={{ - root: classNames(styles['tedi-calendar'], className), + root: classNames(styles['tedi-calendar'], borderlessClass, className), month_caption: styles['tedi-calendar__caption'], head: styles['tedi-calendar__head'], row: styles['tedi-calendar__row'], diff --git a/src/tedi/components/form/filter/filter-group-context.ts b/src/tedi/components/filter/filter/filter-group-context.ts similarity index 100% rename from src/tedi/components/form/filter/filter-group-context.ts rename to src/tedi/components/filter/filter/filter-group-context.ts diff --git a/src/tedi/components/form/filter/filter-group.tsx b/src/tedi/components/filter/filter/filter-group.tsx similarity index 100% rename from src/tedi/components/form/filter/filter-group.tsx rename to src/tedi/components/filter/filter/filter-group.tsx diff --git a/src/tedi/components/form/filter/filter.module.scss b/src/tedi/components/filter/filter/filter.module.scss similarity index 100% rename from src/tedi/components/form/filter/filter.module.scss rename to src/tedi/components/filter/filter/filter.module.scss diff --git a/src/tedi/components/form/filter/filter.spec.tsx b/src/tedi/components/filter/filter/filter.spec.tsx similarity index 100% rename from src/tedi/components/form/filter/filter.spec.tsx rename to src/tedi/components/filter/filter/filter.spec.tsx diff --git a/src/tedi/components/form/filter/filter.stories.tsx b/src/tedi/components/filter/filter/filter.stories.tsx similarity index 92% rename from src/tedi/components/form/filter/filter.stories.tsx rename to src/tedi/components/filter/filter/filter.stories.tsx index dad4e3f6b..c668825ee 100644 --- a/src/tedi/components/form/filter/filter.stories.tsx +++ b/src/tedi/components/filter/filter/filter.stories.tsx @@ -1,26 +1,83 @@ import { Meta, StoryFn, StoryObj } from '@storybook/react'; import { useMemo, useState } from 'react'; +import { DateRange } from 'react-day-picker'; +import { et } from 'react-day-picker/locale'; import { Icon } from '../../base/icon/icon'; import { Text } from '../../base/typography/text/text'; import Button from '../../buttons/button/button'; +import { Calendar, CalendarProps } from '../../content/calendar/calendar'; +import { ChoiceGroup } from '../../form/choice-group'; +import { CalendarView } from '../../form/date-field/date-field'; import { Col, Row } from '../../layout/grid'; import { VerticalSpacing } from '../../layout/vertical-spacing'; import Separator from '../../misc/separator/separator'; import { StatusBadge } from '../../tags/status-badge/status-badge'; import { StatusIndicator } from '../../tags/status-indicator/status-indicator'; import { Tag } from '../../tags/tag/tag'; -import { ChoiceGroup } from '../choice-group'; import { Filter, FilterOption, FilterProps } from './filter'; import { FilterGroup } from './filter-group'; +const formatDayMonthShort = new Intl.DateTimeFormat('et-EE', { day: '2-digit', month: '2-digit', year: '2-digit' }); +const formatRange = (range: DateRange | undefined): string | null => { + if (!range?.from) return null; + const from = formatDayMonthShort.format(range.from); + const to = range.to ? formatDayMonthShort.format(range.to) : '…'; + return `${from} - ${to}`; +}; + +/** + * Small wrapper that hosts a range Calendar inside a Filter's custom-content dropdown. + * Keeps the story bodies readable. + */ +const RangeFilter = ({ + text, + variant, + defaultRange, + numberOfMonths = 2, +}: { + text: string; + variant?: FilterProps['variant']; + defaultRange?: DateRange; + numberOfMonths?: number; +}) => { + const [range, setRange] = useState(defaultRange); + const [currentMonth, setCurrentMonth] = useState(defaultRange?.from ?? new Date()); + const [view, setView] = useState('days'); + + const handleSelect: CalendarProps['handleSelect'] = (selected) => { + setRange(selected as DateRange | undefined); + }; + const applyValue: CalendarProps['applyValue'] = (d) => setCurrentMonth(d); + const triggerText = formatRange(range) ?? text; + + return ( + + + + ); +}; + /** * Figma ↗ */ const meta: Meta = { component: Filter, - title: 'TEDI-Ready/Components/Form/Filter', + title: 'TEDI-Ready/Components/Filter/Filter', parameters: { status: { type: [{ name: 'breakpointSupport', url: '?path=/docs/helpers-usebreakpointprops--usebreakpointprops' }], @@ -164,10 +221,12 @@ export const SingleValueFilter: Story = {
+
+
diff --git a/src/tedi/components/form/filter/filter.tsx b/src/tedi/components/filter/filter/filter.tsx similarity index 99% rename from src/tedi/components/form/filter/filter.tsx rename to src/tedi/components/filter/filter/filter.tsx index 57bdfd437..3d913e1cf 100644 --- a/src/tedi/components/form/filter/filter.tsx +++ b/src/tedi/components/filter/filter/filter.tsx @@ -5,10 +5,10 @@ import { BreakpointSupport, useBreakpointProps } from '../../../helpers'; import { useLabels } from '../../../providers/label-provider'; import { Icon } from '../../base/icon/icon'; import Button from '../../buttons/button/button'; +import Checkbox from '../../form/checkbox/checkbox'; +import { Search } from '../../form/search/search'; import { Dropdown } from '../../overlays/dropdown/dropdown'; import { StatusBadge } from '../../tags/status-badge/status-badge'; -import { Checkbox } from '../checkbox/checkbox'; -import { Search } from '../search/search'; import styles from './filter.module.scss'; import { FilterGroupContext } from './filter-group-context'; diff --git a/src/tedi/components/form/filter/index.ts b/src/tedi/components/filter/filter/index.ts similarity index 100% rename from src/tedi/components/form/filter/index.ts rename to src/tedi/components/filter/filter/index.ts diff --git a/src/tedi/index.ts b/src/tedi/index.ts index 4aee5d06b..d5fb8c0cd 100644 --- a/src/tedi/index.ts +++ b/src/tedi/index.ts @@ -43,7 +43,7 @@ export * from './components/form/select/select'; export * from './components/form/checkbox/checkbox'; export * from './components/form/slider/slider'; export * from './components/form/date-field/date-field'; -export * from './components/form/filter'; +export * from './components/filter/filter'; export * from './components/form/input-group'; export * from './components/form/field/field'; export * from './components/overlays/tooltip'; From 024cfde29ec1ef082f870b7f16ecdf34b14eeaa8 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 14 May 2026 13:45:08 +0300 Subject: [PATCH 03/11] feat(filter): update stories #530 --- .../filter/filter/filter.stories.tsx | 64 +++++++++++++++++-- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/src/tedi/components/filter/filter/filter.stories.tsx b/src/tedi/components/filter/filter/filter.stories.tsx index c668825ee..8ab2195a8 100644 --- a/src/tedi/components/filter/filter/filter.stories.tsx +++ b/src/tedi/components/filter/filter/filter.stories.tsx @@ -88,6 +88,12 @@ const meta: Meta = { url: 'https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.45.70?node-id=6562-159554&m=dev', }, }, + argTypes: { + options: { control: false }, + children: { control: false }, + prepend: { control: false }, + append: { control: false }, + }, }; export default meta; type Story = StoryObj; @@ -221,38 +227,88 @@ export const SingleValueFilter: Story = {
- +
- +
), }; +const arstOptions: FilterOption[] = [ + { label: 'Dr Anna Tamm', value: 'tamm' }, + { label: 'Dr Mari Kask', value: 'kask' }, + { label: 'Dr Jaan Saar', value: 'saar' }, + { label: 'Dr Liis Põld', value: 'pold' }, +]; + +const ajavahemikOptions: FilterOption[] = [ + { label: 'Viimane nädal', value: 'week' }, + { label: 'Viimane kuu', value: 'month' }, + { label: 'Viimane aasta', value: 'year' }, + { label: 'Kohandatud', value: 'custom' }, +]; + /** * Multi value filters open a dropdown with checkboxes. Supports search, "Select all", and - * "Clear selection" out of the box. + * "Clear selection" out of the box. Selected count is shown as a status badge on the trigger. */ export const MultiValueFilter: Story = { render: () => (
- + + + +
+ + +
), From 4224031a233391e1c3136d901180fe6e683f86cf Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Thu, 14 May 2026 15:22:24 +0300 Subject: [PATCH 04/11] feat(filter): fix states responsive #530 --- .../filter/filter/filter.stories.tsx | 114 ++++++++++-------- 1 file changed, 63 insertions(+), 51 deletions(-) diff --git a/src/tedi/components/filter/filter/filter.stories.tsx b/src/tedi/components/filter/filter/filter.stories.tsx index 8ab2195a8..25d441c0b 100644 --- a/src/tedi/components/filter/filter/filter.stories.tsx +++ b/src/tedi/components/filter/filter/filter.stories.tsx @@ -1,8 +1,9 @@ import { Meta, StoryFn, StoryObj } from '@storybook/react'; -import { useMemo, useState } from 'react'; +import { ReactNode, useMemo, useState } from 'react'; import { DateRange } from 'react-day-picker'; import { et } from 'react-day-picker/locale'; +import { isBreakpointBelow, useBreakpoint } from '../../../helpers'; import { Icon } from '../../base/icon/icon'; import { Text } from '../../base/typography/text/text'; import Button from '../../buttons/button/button'; @@ -511,7 +512,9 @@ export const States: Story = { focusVisible: '.pseudo-focus button', }, }, - render: () => { + render: function StatesStory() { + const breakpoint = useBreakpoint(); + const isMobile = isBreakpointBelow(breakpoint, 'md'); const stateRows: { label: string; id: string; selected: boolean }[] = [ { label: 'Default', id: 'default', selected: false }, { label: 'Hover', id: 'hover', selected: false }, @@ -525,59 +528,68 @@ export const States: Story = { { label: 'Hambaarsti vastuvõtt', value: '3' }, ]; + const columns: { label: string; render: (row: (typeof stateRows)[number]) => ReactNode }[] = [ + { label: 'Primary', render: (row) => }, + { + label: 'Primary multiselect', + render: (row) => ( + + ), + }, + { label: 'Secondary', render: (row) => }, + { + label: 'Secondary multiselect', + render: (row) => ( + + ), + }, + { label: 'Large', render: (row) => }, + ]; + return ( - - - State - - - Primary - - - Primary multiselect - - - Secondary - - - Secondary multiselect - - - Large - - - {stateRows.map((row) => ( - - - {row.label} - - - - - - + {!isMobile && ( + + + State - - - - - - - - + {columns.map((c) => ( + + {c.label} + + ))} + + )} + {stateRows.map((row) => ( + + + {row.label} + {columns.map((c) => ( + + {isMobile ? ( + + + {c.label} + + {c.render(row)} + + ) : ( + c.render(row) + )} + + ))} ))} From 961dde737d4f059230900f7e5e1284012ee045da Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 15 May 2026 07:10:00 +0300 Subject: [PATCH 05/11] feat(filter): fix styles #530 --- .../filter/filter/filter.module.scss | 33 ++++++++++--------- .../filter/filter/filter.stories.tsx | 20 ++++++++--- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/tedi/components/filter/filter/filter.module.scss b/src/tedi/components/filter/filter/filter.module.scss index ff5d18245..d5cf97be5 100644 --- a/src/tedi/components/filter/filter/filter.module.scss +++ b/src/tedi/components/filter/filter/filter.module.scss @@ -31,8 +31,11 @@ } &:disabled { + --filter-bg: var(--button-main-disabled-general-background); + --filter-text: var(--button-main-disabled-general-text); + --filter-border: var(--button-main-disabled-general-border); + cursor: not-allowed; - opacity: 0.5; } } @@ -81,9 +84,9 @@ --filter-bg: var(--filter-primary-default-background); --filter-text: var(--filter-primary-default-text); - .tedi-filter__button:hover, - .tedi-filter__button:active, - .tedi-filter__button[aria-expanded='true'] { + .tedi-filter__button:hover:not(:disabled), + .tedi-filter__button:active:not(:disabled), + .tedi-filter__button[aria-expanded='true']:not(:disabled) { --filter-bg: var(--filter-primary-hover-background); --filter-text: var(--filter-primary-hover-text); } @@ -106,9 +109,9 @@ --filter-text: var(--filter-secondary-default-text); --filter-border: var(--filter-secondary-default-border); - .tedi-filter__button:hover, - .tedi-filter__button:active, - .tedi-filter__button[aria-expanded='true'] { + .tedi-filter__button:hover:not(:disabled), + .tedi-filter__button:active:not(:disabled), + .tedi-filter__button[aria-expanded='true']:not(:disabled) { --filter-bg: var(--filter-secondary-hover-background); --filter-text: var(--filter-secondary-hover-text); --filter-border: var(--filter-secondary-hover-border); @@ -120,8 +123,6 @@ --filter-border: var(--filter-secondary-selected-border); --filter-border-width: var(--general-selected-border-width); - // Selected + hover/active keeps the 2px selected border but swaps the surface to the - // secondary hover tint. .tedi-filter__button:hover, .tedi-filter__button:active, .tedi-filter__button[aria-expanded='true'] { @@ -152,18 +153,20 @@ margin-left: calc(-1 * var(--tedi-borders-01)); } - .tedi-filter--primary:not(:last-child) .tedi-filter__button { + .tedi-filter--primary:not(:last-child, .tedi-filter--selected) .tedi-filter__button { border-right: var(--tedi-borders-01) solid var(--filter-secondary-default-border); - // On hover/active the button is filled with the hover bg — match the divider colour so - // it blends into the surface instead of cutting through it. - &:hover, - &:active, - &[aria-expanded='true'] { + &:hover:not(:disabled), + &:active:not(:disabled), + &[aria-expanded='true']:not(:disabled) { border-right-color: var(--filter-primary-hover-background); } } + .tedi-filter--primary.tedi-filter--selected:has(+ .tedi-filter--primary.tedi-filter--selected) .tedi-filter__button { + border-right: var(--tedi-borders-01) solid var(--filter-primary-selected-border); + } + .tedi-filter--selected, .tedi-filter:focus-within { z-index: 1; diff --git a/src/tedi/components/filter/filter/filter.stories.tsx b/src/tedi/components/filter/filter/filter.stories.tsx index 25d441c0b..529639026 100644 --- a/src/tedi/components/filter/filter/filter.stories.tsx +++ b/src/tedi/components/filter/filter/filter.stories.tsx @@ -515,12 +515,13 @@ export const States: Story = { render: function StatesStory() { const breakpoint = useBreakpoint(); const isMobile = isBreakpointBelow(breakpoint, 'md'); - const stateRows: { label: string; id: string; selected: boolean }[] = [ + const stateRows: { label: string; id: string; selected: boolean; disabled?: boolean }[] = [ { label: 'Default', id: 'default', selected: false }, { label: 'Hover', id: 'hover', selected: false }, { label: 'Active', id: 'active', selected: false }, { label: 'Focus', id: 'focus', selected: false }, { label: 'Selected', id: 'selected', selected: true }, + { label: 'Disabled', id: 'disabled', selected: false, disabled: true }, ]; const stateOptions: FilterOption[] = [ { label: 'Optometristi vastuvõtt', value: '1' }, @@ -529,7 +530,10 @@ export const States: Story = { ]; const columns: { label: string; render: (row: (typeof stateRows)[number]) => ReactNode }[] = [ - { label: 'Primary', render: (row) => }, + { + label: 'Primary', + render: (row) => , + }, { label: 'Primary multiselect', render: (row) => ( @@ -538,10 +542,14 @@ export const States: Story = { multiselect options={stateOptions} defaultSelectedValues={row.selected ? ['1', '2'] : []} + disabled={row.disabled} /> ), }, - { label: 'Secondary', render: (row) => }, + { + label: 'Secondary', + render: (row) => , + }, { label: 'Secondary multiselect', render: (row) => ( @@ -551,10 +559,14 @@ export const States: Story = { multiselect options={stateOptions} defaultSelectedValues={row.selected ? ['1', '2'] : []} + disabled={row.disabled} /> ), }, - { label: 'Large', render: (row) => }, + { + label: 'Large', + render: (row) => , + }, ]; return ( From 4fe42f110e566e5543fbcbcae53ca7bd86386ca6 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 15 May 2026 07:14:43 +0300 Subject: [PATCH 06/11] fix(filter): clear selection styles fix #530 --- src/tedi/components/filter/filter/filter.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tedi/components/filter/filter/filter.module.scss b/src/tedi/components/filter/filter/filter.module.scss index d5cf97be5..e8e3ed53d 100644 --- a/src/tedi/components/filter/filter/filter.module.scss +++ b/src/tedi/components/filter/filter/filter.module.scss @@ -202,7 +202,7 @@ &__clear { display: flex; justify-content: center; - padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x); + padding: 0 var(--dropdown-item-padding-x); background: var(--dropdown-item-default-background); } } From d200f2002d16215e01be621b17c7bc9cf7aec867 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Mon, 18 May 2026 08:58:09 +0300 Subject: [PATCH 07/11] fix(filter): fix disabled styles, update core, update states example #530 --- package-lock.json | 8 +- package.json | 2 +- .../filter/filter/filter.module.scss | 35 +++-- .../filter/filter/filter.stories.tsx | 143 ++++++++++-------- src/tedi/components/filter/filter/filter.tsx | 2 +- 5 files changed, 107 insertions(+), 83 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1dde3b77a..889331fa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@mui/material": "^5.15.13", "@mui/x-date-pickers": "^5.0.20", "@tanstack/react-table": "^8.13.2", - "@tedi-design-system/core": "6.0.1", + "@tedi-design-system/core": "6.2.0", "classnames": "^2.5.1", "draft-js": "^0.11.7", "draftjs-md-converter": "^1.5.2", @@ -7955,9 +7955,9 @@ } }, "node_modules/@tedi-design-system/core": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-6.0.1.tgz", - "integrity": "sha512-SgWbcIofn/LSzGbHYPYZD7i3PPdeL/qTaq99QT+RY6i9ISYViHQcrtNDiLZogx5KrREl/mQcrl3sLZNwmU6bDg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-6.2.0.tgz", + "integrity": "sha512-KWlZpWEqiEKZ3Mq9quDPsvzXaaXxKZMlS7BbbgwuSIrlF8vf3qKFRc8BA+WhITZHCP5Z/LmdT4Z3ueCDix2KBA==", "engines": { "node": ">=24.0.0", "npm": ">=11.0.0" diff --git a/package.json b/package.json index 55be579d6..bf7382ce9 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@mui/material": "^5.15.13", "@mui/x-date-pickers": "^5.0.20", "@tanstack/react-table": "^8.13.2", - "@tedi-design-system/core": "6.0.1", + "@tedi-design-system/core": "6.2.0", "classnames": "^2.5.1", "draft-js": "^0.11.7", "draftjs-md-converter": "^1.5.2", diff --git a/src/tedi/components/filter/filter/filter.module.scss b/src/tedi/components/filter/filter/filter.module.scss index e8e3ed53d..da7e8ca4f 100644 --- a/src/tedi/components/filter/filter/filter.module.scss +++ b/src/tedi/components/filter/filter/filter.module.scss @@ -31,10 +31,6 @@ } &:disabled { - --filter-bg: var(--button-main-disabled-general-background); - --filter-text: var(--button-main-disabled-general-text); - --filter-border: var(--button-main-disabled-general-border); - cursor: not-allowed; } } @@ -91,13 +87,25 @@ --filter-text: var(--filter-primary-hover-text); } + .tedi-filter__button:disabled { + --filter-bg: var(--filter-primary-disabled-background); + --filter-text: var(--filter-primary-disabled-text); + --filter-border: var(--filter-primary-disabled-border); + --filter-border-width: var(--tedi-borders-01); + + // Force the count `StatusBadge` (which uses `color="neutral"` when the filter + // is disabled) to inherit the filter's disabled text token so the badge stays + // visually grouped with the rest of the disabled chip. + --status-badge-text-neutral: var(--filter-primary-disabled-text); + } + &.tedi-filter--selected { --filter-bg: var(--filter-primary-selected-background); --filter-text: var(--filter-primary-selected-text); - .tedi-filter__button:hover, - .tedi-filter__button:active, - .tedi-filter__button[aria-expanded='true'] { + .tedi-filter__button:hover:not(:disabled), + .tedi-filter__button:active:not(:disabled), + .tedi-filter__button[aria-expanded='true']:not(:disabled) { --filter-bg: var(--filter-primary-hover-background); --filter-text: var(--filter-primary-hover-text); } @@ -117,15 +125,22 @@ --filter-border: var(--filter-secondary-hover-border); } + .tedi-filter__button:disabled { + --filter-bg: var(--filter-secondary-disabled-background); + --filter-text: var(--filter-secondary-disabled-text); + --filter-border: var(--filter-secondary-disabled-border); + --status-badge-text-neutral: var(--filter-secondary-disabled-text); + } + &.tedi-filter--selected { --filter-bg: var(--filter-secondary-selected-background); --filter-text: var(--filter-secondary-selected-text); --filter-border: var(--filter-secondary-selected-border); --filter-border-width: var(--general-selected-border-width); - .tedi-filter__button:hover, - .tedi-filter__button:active, - .tedi-filter__button[aria-expanded='true'] { + .tedi-filter__button:hover:not(:disabled), + .tedi-filter__button:active:not(:disabled), + .tedi-filter__button[aria-expanded='true']:not(:disabled) { --filter-bg: var(--filter-secondary-hover-background); --filter-border: var(--filter-secondary-hover-border); } diff --git a/src/tedi/components/filter/filter/filter.stories.tsx b/src/tedi/components/filter/filter/filter.stories.tsx index 529639026..3e50c964c 100644 --- a/src/tedi/components/filter/filter/filter.stories.tsx +++ b/src/tedi/components/filter/filter/filter.stories.tsx @@ -1,5 +1,5 @@ import { Meta, StoryFn, StoryObj } from '@storybook/react'; -import { ReactNode, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { DateRange } from 'react-day-picker'; import { et } from 'react-day-picker/locale'; @@ -504,6 +504,13 @@ export const CustomizeContent: Story = { ), }; +/** + * Mirrors the Figma states grid (`4612:83728`) — one table per variant + * (Primary, Secondary), each with five rows (Default, Hover, Active, Focus, + * Disabled) × two columns (Not selected, Selected). Each cell renders both a + * plain text Filter and a multi-select Filter with a count badge so the count + * appearance is covered alongside the bare button. + */ export const States: Story = { parameters: { pseudo: { @@ -515,13 +522,12 @@ export const States: Story = { render: function StatesStory() { const breakpoint = useBreakpoint(); const isMobile = isBreakpointBelow(breakpoint, 'md'); - const stateRows: { label: string; id: string; selected: boolean; disabled?: boolean }[] = [ - { label: 'Default', id: 'default', selected: false }, - { label: 'Hover', id: 'hover', selected: false }, - { label: 'Active', id: 'active', selected: false }, - { label: 'Focus', id: 'focus', selected: false }, - { label: 'Selected', id: 'selected', selected: true }, - { label: 'Disabled', id: 'disabled', selected: false, disabled: true }, + const stateRows: { label: string; id: string; disabled?: boolean }[] = [ + { label: 'Default', id: 'default' }, + { label: 'Hover', id: 'hover' }, + { label: 'Active', id: 'active' }, + { label: 'Focus', id: 'focus' }, + { label: 'Disabled', id: 'disabled', disabled: true }, ]; const stateOptions: FilterOption[] = [ { label: 'Optometristi vastuvõtt', value: '1' }, @@ -529,83 +535,86 @@ export const States: Story = { { label: 'Hambaarsti vastuvõtt', value: '3' }, ]; - const columns: { label: string; render: (row: (typeof stateRows)[number]) => ReactNode }[] = [ - { - label: 'Primary', - render: (row) => , - }, - { - label: 'Primary multiselect', - render: (row) => ( - - ), - }, - { - label: 'Secondary', - render: (row) => , - }, - { - label: 'Secondary multiselect', - render: (row) => ( + const variants: { label: string; variant: FilterProps['variant'] }[] = [ + { label: 'Primary', variant: 'primary' }, + { label: 'Secondary', variant: 'secondary' }, + ]; + + const renderCell = (variant: FilterProps['variant'], selected: boolean, disabled: boolean | undefined) => ( + + + + + - ), - }, - { - label: 'Large', - render: (row) => , - }, - ]; + + + ); - return ( - + const renderTable = (label: string, variant: FilterProps['variant']) => ( + + + {label} + {!isMobile && ( - - State + + + Not selected + + + Selected - {columns.map((c) => ( - - {c.label} - - ))} )} {stateRows.map((row) => ( - + {row.label} - {columns.map((c) => ( - - {isMobile ? ( - - - {c.label} - - {c.render(row)} - - ) : ( - c.render(row) - )} - - ))} + + {isMobile ? ( + + + Not selected + + {renderCell(variant, false, row.disabled)} + + ) : ( + renderCell(variant, false, row.disabled) + )} + + + {isMobile ? ( + + + Selected + + {renderCell(variant, true, row.disabled)} + + ) : ( + renderCell(variant, true, row.disabled) + )} + ))} ); + + return ( + + {variants.map((v) => ( +
{renderTable(v.label, v.variant)}
+ ))} +
+ ); }, }; diff --git a/src/tedi/components/filter/filter/filter.tsx b/src/tedi/components/filter/filter/filter.tsx index 3d913e1cf..beb9c24c0 100644 --- a/src/tedi/components/filter/filter/filter.tsx +++ b/src/tedi/components/filter/filter/filter.tsx @@ -475,7 +475,7 @@ export const Filter = forwardRef((props, ref) => {displayText} {append !== undefined && append !== null && {append}} {isMultiSelect && isSelected && multiValues.length > 0 && ( - + {String(multiValues.length)} )} From fd90f9368a5808d92abdb013fa7e51ca6a37ce39 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Mon, 18 May 2026 09:03:02 +0300 Subject: [PATCH 08/11] fix(filter): remove uneccessary comments #530 --- src/tedi/components/filter/filter/filter.stories.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/tedi/components/filter/filter/filter.stories.tsx b/src/tedi/components/filter/filter/filter.stories.tsx index 3e50c964c..f45f5a237 100644 --- a/src/tedi/components/filter/filter/filter.stories.tsx +++ b/src/tedi/components/filter/filter/filter.stories.tsx @@ -504,13 +504,6 @@ export const CustomizeContent: Story = { ), }; -/** - * Mirrors the Figma states grid (`4612:83728`) — one table per variant - * (Primary, Secondary), each with five rows (Default, Hover, Active, Focus, - * Disabled) × two columns (Not selected, Selected). Each cell renders both a - * plain text Filter and a multi-select Filter with a count badge so the count - * appearance is covered alongside the bare button. - */ export const States: Story = { parameters: { pseudo: { @@ -889,7 +882,6 @@ export const Examples: Story = { - {/* — Section 2: Andmed (single-select FilterGroup) */} Andmed @@ -924,7 +916,6 @@ export const Examples: Story = { - {/* — Section 3: Menetlusdokumendid (multi-select FilterGroup) */} Menetlusdokumendid @@ -949,7 +940,6 @@ export const Examples: Story = { - {/* — Section 4: Taotlused (primary variant) */} Taotlused (primary) From 9b283f410f2c9c19130e526f6dca6b5749fb5324 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Mon, 18 May 2026 10:29:29 +0300 Subject: [PATCH 09/11] feat(filter): improve test coverage #530 --- .../components/filter/filter/filter.spec.tsx | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/tedi/components/filter/filter/filter.spec.tsx b/src/tedi/components/filter/filter/filter.spec.tsx index 0ee7ba013..0bd76a52c 100644 --- a/src/tedi/components/filter/filter/filter.spec.tsx +++ b/src/tedi/components/filter/filter/filter.spec.tsx @@ -160,6 +160,24 @@ describe('Filter — multi-select dropdown', () => { expect(onChange).toHaveBeenLastCalledWith(['a', 'b']); }); + it('select-all deselects all when every filtered option is already selected', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render( + + ); + await user.click(screen.getByRole('button')); + await user.click(screen.getByRole('checkbox', { name: /vali kõik/i })); + expect(onChange).toHaveBeenLastCalledWith([]); + }); + it('search input filters the visible options', async () => { const user = userEvent.setup(); render(); @@ -290,3 +308,58 @@ describe('FilterGroup — unmanaged', () => { expect(buttons[1]).toHaveAttribute('aria-pressed', 'false'); }); }); + +describe('FilterGroup — uncontrolled', () => { + it('single-select tracks the selection in internal state when `value` is not provided', async () => { + const user = userEvent.setup(); + const onValueChange = jest.fn(); + render( + + + + + ); + + const [foo, bar] = screen.getAllByRole('radio'); + await user.click(foo); + expect(foo).toBeChecked(); + expect(bar).not.toBeChecked(); + expect(onValueChange).toHaveBeenLastCalledWith('foo'); + + await user.click(bar); + expect(foo).not.toBeChecked(); + expect(bar).toBeChecked(); + expect(onValueChange).toHaveBeenLastCalledWith('bar'); + + // Clicking the active one toggles it back to null. + await user.click(bar); + expect(bar).not.toBeChecked(); + expect(onValueChange).toHaveBeenLastCalledWith(null); + }); + + it('multi-select tracks the selection in internal state when `values` is not provided', async () => { + const user = userEvent.setup(); + const onValuesChange = jest.fn(); + render( + + + + + ); + + const [foo, bar] = screen.getAllByRole('button'); + await user.click(foo); + expect(foo).toHaveAttribute('aria-pressed', 'true'); + expect(onValuesChange).toHaveBeenLastCalledWith(['foo']); + + await user.click(bar); + expect(foo).toHaveAttribute('aria-pressed', 'true'); + expect(bar).toHaveAttribute('aria-pressed', 'true'); + expect(onValuesChange).toHaveBeenLastCalledWith(['foo', 'bar']); + + // Toggle one off. + await user.click(foo); + expect(foo).toHaveAttribute('aria-pressed', 'false'); + expect(onValuesChange).toHaveBeenLastCalledWith(['bar']); + }); +}); From f9dffc3ea4cba26a8e8185db8c7fe1fac2563fff Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Mon, 18 May 2026 10:57:39 +0300 Subject: [PATCH 10/11] fix(filter): cr fixes #530 --- .../components/filter/filter/filter-group.tsx | 42 ++++++++- .../components/filter/filter/filter.spec.tsx | 92 +++++++++++++++++++ src/tedi/components/filter/filter/filter.tsx | 61 ++++++++---- src/tedi/components/form/search/search.tsx | 2 +- 4 files changed, 173 insertions(+), 24 deletions(-) diff --git a/src/tedi/components/filter/filter/filter-group.tsx b/src/tedi/components/filter/filter/filter-group.tsx index d36c254b3..a6cc29cd2 100644 --- a/src/tedi/components/filter/filter/filter-group.tsx +++ b/src/tedi/components/filter/filter/filter-group.tsx @@ -8,11 +8,20 @@ interface FilterGroupCommonProps { /** * Accessible label for the group, exposed as `aria-label` on the container. * - * Recommended whenever the group is managed (single- or multi-select) so screen readers - * announce the radio/group semantics with context (e.g. "Status, radio group"). Setting - * `label` also implicitly turns the group into managed mode. + * **Required when the group is managed** (single- or multi-select). The container then + * has `role="radiogroup"` / `role="group"`, and ARIA requires every role to carry an + * accessible name — otherwise screen readers announce e.g. "radio group" with no context. + * Use `ariaLabelledBy` instead if the name lives in an existing heading. + * + * Setting `label` also implicitly turns the group into managed mode. */ label?: string; + /** + * ID of an existing element that labels the group (alternative to `label`). Useful when + * the group is preceded by a heading that should also act as the accessible name — + * avoids duplicating the text. Sets `aria-labelledby` on the container. + */ + ariaLabelledBy?: string; /** * When `true`, every `` inside the group is disabled, regardless of their own * `disabled` props. Useful for "this section isn't applicable yet" UX. @@ -97,9 +106,17 @@ type FilterGroupInternalProps = FilterGroupCommonProps & { onValuesChange?: (values: string[]) => void; }; +/** + * Tracks groups that already warned so we only nag once per render cycle. + * Module-scoped Set is fine: the warning only fires in development and the + * cost of holding a few strings is trivial. + */ +const warnedGroups = new Set(); + export const FilterGroup = (props: FilterGroupProps): JSX.Element => { const { label, + ariaLabelledBy, disabled = false, className, children, @@ -168,9 +185,26 @@ export const FilterGroup = (props: FilterGroupProps): JSX.Element => { const role = isManaged ? (multiselect ? 'group' : 'radiogroup') : undefined; + if (process.env.NODE_ENV !== 'production' && isManaged && !label && !ariaLabelledBy) { + const key = role + '|' + (controlledValue ?? defaultValue ?? '') + '|' + multiselect; + if (!warnedGroups.has(key)) { + warnedGroups.add(key); + // eslint-disable-next-line no-console + console.warn( + `[FilterGroup] role="${role}" needs an accessible name. Pass either \`label\` or ` + + '`ariaLabelledBy` so screen readers can announce the group with context.' + ); + } + } + return ( -
+
{children}
diff --git a/src/tedi/components/filter/filter/filter.spec.tsx b/src/tedi/components/filter/filter/filter.spec.tsx index 0bd76a52c..9420b0c5c 100644 --- a/src/tedi/components/filter/filter/filter.spec.tsx +++ b/src/tedi/components/filter/filter/filter.spec.tsx @@ -220,6 +220,28 @@ describe('Filter — custom dropdown content', () => { await user.click(screen.getByRole('button', { name: /tühjenda valik/i })); expect(onClear).toHaveBeenCalled(); }); + + it('warns when defaultSelected / onSelectedChange are passed alongside custom-content children', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + render( + undefined}> +
custom panel
+
+ ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('not honoured in custom-content mode')); + warn.mockRestore(); + }); + + it('does not warn when custom-content filter uses the controlled `selected` prop', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + render( + +
custom panel
+
+ ); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); }); describe('FilterGroup — single-select (radiogroup)', () => { @@ -309,6 +331,76 @@ describe('FilterGroup — unmanaged', () => { }); }); +describe('FilterGroup — dropdown filters keep their own selection state', () => { + // Regression: when a Filter with `options` (single- or multi-select) lives inside a + // managed FilterGroup, the chip used to read `isSelected` from `group.isSelected(...)`, + // which was never updated by the dropdown commit paths. The chip stayed unselected + // even after the user picked a value. + it('single-select dropdown inside a managed group reflects the dropdown selection', () => { + const options = [ + { value: 'a', label: 'Option A' }, + { value: 'b', label: 'Option B' }, + ]; + const { container } = render( + + + + ); + + // Pre-fix the wrapper read `isSelected` from the group (unset), so this + // class was missing even though the dropdown had a selected value. + expect(container.querySelector('.tedi-filter')?.className).toMatch(/tedi-filter--selected/); + }); + + it('multi-select dropdown inside a managed group reflects the dropdown selection', async () => { + const user = userEvent.setup(); + const options = [ + { value: 'a', label: 'Option A' }, + { value: 'b', label: 'Option B' }, + ]; + render( + + + + ); + + await user.click(screen.getByRole('button', { name: /tags/i })); + await user.click(screen.getByRole('checkbox', { name: 'Option A' })); + // The count badge renders only when `isSelected` is true. Pre-fix the chip + // read `isSelected` from the unset group state, so the badge never appeared. + expect(screen.getByText('1')).toBeInTheDocument(); + }); +}); + +describe('FilterGroup — accessible name', () => { + it('uses ariaLabelledBy when set (alternative to `label`)', () => { + render( + <> +

Tags

+ + + + + + ); + const group = screen.getByRole('group'); + expect(group).toHaveAttribute('aria-labelledby', 'tags-heading'); + expect(group).not.toHaveAttribute('aria-label'); + }); + + it('warns in dev when a managed group has no accessible name', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + render( + + + + + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('needs an accessible name')); + warn.mockRestore(); + }); +}); + describe('FilterGroup — uncontrolled', () => { it('single-select tracks the selection in internal state when `value` is not provided', async () => { const user = userEvent.setup(); diff --git a/src/tedi/components/filter/filter/filter.tsx b/src/tedi/components/filter/filter/filter.tsx index beb9c24c0..2b70d0d9a 100644 --- a/src/tedi/components/filter/filter/filter.tsx +++ b/src/tedi/components/filter/filter/filter.tsx @@ -77,28 +77,36 @@ export interface FilterProps extends BreakpointSupport { */ id?: string; /** - * **Controlled** selected state for **toggle** mode (no options, no children) and - * **custom-content** mode (children provided). - * - * Provide this together with `onSelectedChange` to fully own the state from outside. - * Ignored in single-select and multi-select modes — those derive `isSelected` from - * `selectedValue` / `selectedValues` respectively. + * Selected appearance flag. + * + * - **Toggle mode** (no `options`, no `children`): can be either controlled + * (pair with `onSelectedChange`) or uncontrolled (use `defaultSelected`). + * - **Custom-content mode** (`children` provided): **controlled-only**. The + * Filter can't know what counts as "selected" inside your custom dropdown, + * so you must drive `selected` yourself based on the picked value + * (e.g. `selected={Boolean(dateRange?.from)}`). `defaultSelected` and + * `onSelectedChange` are not honoured in this mode — the dropdown open / + * close toggle doesn't fire either of them. + * - **Single-select / multi-select modes** (`options` provided): ignored. + * The selected appearance is derived from `selectedValue` / `selectedValues`. */ selected?: boolean; /** - * **Uncontrolled** initial selected state for toggle / custom-content mode. + * **Uncontrolled** initial selected state — **toggle mode only** (no `options`, + * no `children`). For custom-content filters the toggle event isn't wired + * (the click opens the dropdown instead), so this would never update — + * pass `selected` as a controlled prop instead. * - * Use this when you don't need to read the state from outside; the component manages - * the value internally. Ignored when `selected` is also provided (controlled mode wins). + * Ignored when `selected` is also provided (controlled mode wins). * * @default false */ defaultSelected?: boolean; /** - * Fires whenever the toggle state changes, receiving the new boolean. - * - * Called in both controlled and uncontrolled modes. Not called in single- or multi-select - * modes — see `onSelectedValueChange` / `onSelectedValuesChange`. + * Fires whenever the toggle state changes — **toggle mode only**. Custom-content + * filters don't fire this callback (the click opens the dropdown; closing the + * dropdown doesn't toggle a boolean). Single- and multi-select modes use + * `onSelectedValueChange` / `onSelectedValuesChange` respectively. */ onSelectedChange?: (selected: boolean) => void; /** @@ -325,23 +333,38 @@ export const Filter = forwardRef((props, ref) => const isSingleSelect = hasOptions && !multiselect; const isMultiSelect = hasOptions && multiselect; const isGrouped = Boolean(group?.isManaged); - const isGroupedRadio = isGrouped && !group?.multiselect; + const groupOwnsSelection = isGrouped && groupValue !== undefined && !hasDropdown; + const isGroupedRadio = groupOwnsSelection && !group?.multiselect; const disabled = disabledProp || (group?.disabled ?? false); const [innerSelected, setInnerSelected] = useState(defaultSelected); const [innerSingle, setInnerSingle] = useState(defaultSelectedValue ?? ''); const [innerMulti, setInnerMulti] = useState(defaultSelectedValues ?? []); + if ( + process.env.NODE_ENV !== 'production' && + hasCustomContent && + (defaultSelected || onSelectedChange) && + selectedProp === undefined + ) { + // eslint-disable-next-line no-console + console.warn( + '[Filter] `defaultSelected` and `onSelectedChange` are not honoured in custom-content mode ' + + '(when `children` is provided). Drive the selected state yourself via the controlled `selected` prop, ' + + 'e.g. `selected={Boolean(value)}`.' + ); + } + const toggleSelected = selectedProp !== undefined ? selectedProp : innerSelected; const singleValue = selectedValue !== undefined ? selectedValue : innerSingle; const multiValues = selectedValues !== undefined ? selectedValues : innerMulti; const isSelected = useMemo(() => { - if (isGrouped && groupValue !== undefined) return group!.isSelected(groupValue); + if (groupOwnsSelection) return group!.isSelected(groupValue!); if (isMultiSelect) return multiValues.length > 0; if (isSingleSelect) return singleValue !== ''; return toggleSelected; - }, [isGrouped, groupValue, group, isMultiSelect, multiValues, isSingleSelect, singleValue, toggleSelected]); + }, [groupOwnsSelection, groupValue, group, isMultiSelect, multiValues, isSingleSelect, singleValue, toggleSelected]); const selectedLabel = useMemo(() => { if (!isSingleSelect || !singleValue) return null; @@ -390,14 +413,14 @@ export const Filter = forwardRef((props, ref) => ); const handleToggle = useCallback(() => { - if (isGrouped && groupValue !== undefined) { - group!.selectFilter(groupValue); + if (groupOwnsSelection) { + group!.selectFilter(groupValue!); return; } const next = !toggleSelected; if (selectedProp === undefined) setInnerSelected(next); onSelectedChange?.(next); - }, [isGrouped, group, groupValue, toggleSelected, selectedProp, onSelectedChange]); + }, [groupOwnsSelection, group, groupValue, toggleSelected, selectedProp, onSelectedChange]); const commitSingle = useCallback( (val: string) => { diff --git a/src/tedi/components/form/search/search.tsx b/src/tedi/components/form/search/search.tsx index 55500f3f0..dba116466 100644 --- a/src/tedi/components/form/search/search.tsx +++ b/src/tedi/components/form/search/search.tsx @@ -62,7 +62,7 @@ export const Search = forwardRef( isClearable, onKeyDown: handleKeyDown, onChange, - input: { role: 'searchbox' as const, ...input }, + input: { ...input, role: 'searchbox' as const }, ...(button ? {} : { icon: searchIcon }), }; From 42baad22992bf552087a0d43ac20e7245a553471 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 20 May 2026 14:02:07 +0300 Subject: [PATCH 11/11] feat(filter): add active state variables #530 --- package-lock.json | 8 ++--- package.json | 2 +- .../filter/filter/filter.module.scss | 30 +++++++++++++++---- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 889331fa5..51b5c4f15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@mui/material": "^5.15.13", "@mui/x-date-pickers": "^5.0.20", "@tanstack/react-table": "^8.13.2", - "@tedi-design-system/core": "6.2.0", + "@tedi-design-system/core": "6.2.1", "classnames": "^2.5.1", "draft-js": "^0.11.7", "draftjs-md-converter": "^1.5.2", @@ -7955,9 +7955,9 @@ } }, "node_modules/@tedi-design-system/core": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-6.2.0.tgz", - "integrity": "sha512-KWlZpWEqiEKZ3Mq9quDPsvzXaaXxKZMlS7BbbgwuSIrlF8vf3qKFRc8BA+WhITZHCP5Z/LmdT4Z3ueCDix2KBA==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-6.2.1.tgz", + "integrity": "sha512-lbkORWY9TpvY1DAx5NTH9fyENeyoW0TBpfwoFBHuWZM9Jdyb2Oy/840BRjyIvLCLKSxkod8qS7B6bNrVCxRqcA==", "engines": { "node": ">=24.0.0", "npm": ">=11.0.0" diff --git a/package.json b/package.json index bf7382ce9..57a0c462a 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@mui/material": "^5.15.13", "@mui/x-date-pickers": "^5.0.20", "@tanstack/react-table": "^8.13.2", - "@tedi-design-system/core": "6.2.0", + "@tedi-design-system/core": "6.2.1", "classnames": "^2.5.1", "draft-js": "^0.11.7", "draftjs-md-converter": "^1.5.2", diff --git a/src/tedi/components/filter/filter/filter.module.scss b/src/tedi/components/filter/filter/filter.module.scss index da7e8ca4f..c5e3545d8 100644 --- a/src/tedi/components/filter/filter/filter.module.scss +++ b/src/tedi/components/filter/filter/filter.module.scss @@ -81,12 +81,16 @@ --filter-text: var(--filter-primary-default-text); .tedi-filter__button:hover:not(:disabled), - .tedi-filter__button:active:not(:disabled), .tedi-filter__button[aria-expanded='true']:not(:disabled) { --filter-bg: var(--filter-primary-hover-background); --filter-text: var(--filter-primary-hover-text); } + .tedi-filter__button:active:not(:disabled) { + --filter-bg: var(--filter-primary-active-background); + --filter-text: var(--filter-primary-active-text); + } + .tedi-filter__button:disabled { --filter-bg: var(--filter-primary-disabled-background); --filter-text: var(--filter-primary-disabled-text); @@ -104,11 +108,15 @@ --filter-text: var(--filter-primary-selected-text); .tedi-filter__button:hover:not(:disabled), - .tedi-filter__button:active:not(:disabled), .tedi-filter__button[aria-expanded='true']:not(:disabled) { --filter-bg: var(--filter-primary-hover-background); --filter-text: var(--filter-primary-hover-text); } + + .tedi-filter__button:active:not(:disabled) { + --filter-bg: var(--filter-primary-active-background); + --filter-text: var(--filter-primary-active-text); + } } } @@ -118,13 +126,18 @@ --filter-border: var(--filter-secondary-default-border); .tedi-filter__button:hover:not(:disabled), - .tedi-filter__button:active:not(:disabled), .tedi-filter__button[aria-expanded='true']:not(:disabled) { --filter-bg: var(--filter-secondary-hover-background); --filter-text: var(--filter-secondary-hover-text); --filter-border: var(--filter-secondary-hover-border); } + .tedi-filter__button:active:not(:disabled) { + --filter-bg: var(--filter-secondary-active-background); + --filter-text: var(--filter-secondary-active-text); + --filter-border: var(--filter-secondary-active-border); + } + .tedi-filter__button:disabled { --filter-bg: var(--filter-secondary-disabled-background); --filter-text: var(--filter-secondary-disabled-text); @@ -139,11 +152,15 @@ --filter-border-width: var(--general-selected-border-width); .tedi-filter__button:hover:not(:disabled), - .tedi-filter__button:active:not(:disabled), .tedi-filter__button[aria-expanded='true']:not(:disabled) { --filter-bg: var(--filter-secondary-hover-background); --filter-border: var(--filter-secondary-hover-border); } + + .tedi-filter__button:active:not(:disabled) { + --filter-bg: var(--filter-secondary-active-background); + --filter-border: var(--filter-secondary-active-border); + } } } @@ -172,10 +189,13 @@ border-right: var(--tedi-borders-01) solid var(--filter-secondary-default-border); &:hover:not(:disabled), - &:active:not(:disabled), &[aria-expanded='true']:not(:disabled) { border-right-color: var(--filter-primary-hover-background); } + + &:active:not(:disabled) { + border-right-color: var(--filter-primary-active-background); + } } .tedi-filter--primary.tedi-filter--selected:has(+ .tedi-filter--primary.tedi-filter--selected) .tedi-filter__button {