Skip to content

Commit 58356cd

Browse files
feat: Tag Filter component (#1087)
Add TagFilter component, which uses a set of selectable tags as a multi select filter. Signed-off-by: Adhitya Mamallan <[email protected]>
1 parent 58411ff commit 58356cd

File tree

8 files changed

+333
-0
lines changed

8 files changed

+333
-0
lines changed
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { render, screen, userEvent } from '@/test-utils/rtl';
2+
3+
import TagFilter from '../tag-filter';
4+
import { type TagFilterOptionConfig } from '../tag-filter.types';
5+
6+
const MOCK_OPTIONS_CONFIG = {
7+
opt1: {
8+
label: 'Option 1',
9+
startEnhancer: () => <div data-testid="enhancer-opt1" />,
10+
},
11+
opt2: {
12+
label: 'Option 2',
13+
},
14+
opt3: {
15+
label: 'Option 3',
16+
startEnhancer: () => <div data-testid="enhancer-opt3" />,
17+
},
18+
} as const satisfies Record<string, TagFilterOptionConfig>;
19+
20+
type MockOption = keyof typeof MOCK_OPTIONS_CONFIG;
21+
22+
describe(TagFilter.name, () => {
23+
it('renders label, "Show all" tag, and all tags from optionsConfig with enhancers', () => {
24+
setup({});
25+
26+
expect(screen.getByText('Mock Label')).toBeInTheDocument();
27+
expect(screen.getByText('Show all')).toBeInTheDocument();
28+
expect(screen.getByText('Option 1')).toBeInTheDocument();
29+
expect(screen.getByText('Option 2')).toBeInTheDocument();
30+
expect(screen.getByText('Option 3')).toBeInTheDocument();
31+
expect(screen.getByTestId('enhancer-opt1')).toBeInTheDocument();
32+
expect(screen.queryByTestId('enhancer-opt2')).not.toBeInTheDocument();
33+
expect(screen.getByTestId('enhancer-opt3')).toBeInTheDocument();
34+
});
35+
36+
it('calls onChangeValues when clicking an individual tag to select it', async () => {
37+
const { user, mockOnChangeValues } = setup({
38+
values: [],
39+
});
40+
41+
const option1Tag = screen.getByText('Option 1');
42+
await user.click(option1Tag);
43+
44+
expect(mockOnChangeValues).toHaveBeenCalledWith(['opt1']);
45+
});
46+
47+
it('calls onChangeValues when clicking an individual tag to deselect it', async () => {
48+
const { user, mockOnChangeValues } = setup({
49+
values: ['opt1', 'opt2'],
50+
});
51+
52+
const option1Tag = screen.getByText('Option 1');
53+
await user.click(option1Tag);
54+
55+
expect(mockOnChangeValues).toHaveBeenCalledWith(['opt2']);
56+
});
57+
58+
it('selects all tags when "Show all" is clicked and no values are currently selected', async () => {
59+
const { user, mockOnChangeValues } = setup({
60+
values: [],
61+
});
62+
63+
const showAllTag = screen.getByText('Show all');
64+
await user.click(showAllTag);
65+
66+
expect(mockOnChangeValues).toHaveBeenCalledWith(['opt1', 'opt2', 'opt3']);
67+
});
68+
69+
it('deselects all tags when "Show all" is clicked and all values are currently selected', async () => {
70+
const { user, mockOnChangeValues } = setup({
71+
values: ['opt1', 'opt2', 'opt3'],
72+
});
73+
74+
const showAllTag = screen.getByText('Show all');
75+
await user.click(showAllTag);
76+
77+
expect(mockOnChangeValues).toHaveBeenCalledWith([]);
78+
});
79+
80+
it('selects all tags when "Show all" is clicked and only some values are currently selected', async () => {
81+
const { user, mockOnChangeValues } = setup({
82+
values: ['opt1'],
83+
});
84+
85+
const showAllTag = screen.getByText('Show all');
86+
await user.click(showAllTag);
87+
88+
expect(mockOnChangeValues).toHaveBeenCalledWith(['opt1', 'opt2', 'opt3']);
89+
});
90+
91+
it('does not render "Show all" tag when hideShowAll is true', () => {
92+
setup({
93+
hideShowAll: true,
94+
});
95+
96+
expect(screen.queryByText('Show all')).not.toBeInTheDocument();
97+
expect(screen.getByText('Option 1')).toBeInTheDocument();
98+
expect(screen.getByText('Option 2')).toBeInTheDocument();
99+
expect(screen.getByText('Option 3')).toBeInTheDocument();
100+
});
101+
});
102+
103+
function setup({
104+
label = 'Mock Label',
105+
values = [],
106+
optionsConfig = MOCK_OPTIONS_CONFIG,
107+
hideShowAll = false,
108+
}: {
109+
label?: string;
110+
values?: Array<MockOption>;
111+
optionsConfig?: Record<MockOption, TagFilterOptionConfig>;
112+
hideShowAll?: boolean;
113+
} = {}) {
114+
const mockOnChangeValues = jest.fn();
115+
const user = userEvent.setup();
116+
117+
render(
118+
<TagFilter
119+
label={label}
120+
values={values}
121+
onChangeValues={mockOnChangeValues}
122+
optionsConfig={optionsConfig}
123+
hideShowAll={hideShowAll}
124+
/>
125+
);
126+
127+
return { user, mockOnChangeValues };
128+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import React from 'react';
2+
3+
import { render, screen } from '@/test-utils/rtl';
4+
5+
import SelectableTag from '../selectable-tag';
6+
7+
describe(SelectableTag.name, () => {
8+
it('renders correctly', () => {
9+
render(
10+
<SelectableTag value={false} onClick={jest.fn()}>
11+
Test Tag
12+
</SelectableTag>
13+
);
14+
15+
expect(screen.getByText('Test Tag')).toBeInTheDocument();
16+
});
17+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { type Theme } from 'baseui';
2+
import { type TagKind, type TagOverrides } from 'baseui/tag';
3+
import { type StyleObject } from 'styletron-react';
4+
5+
export const overrides = {
6+
tag: {
7+
Root: {
8+
style: ({
9+
$theme,
10+
$kind,
11+
}: {
12+
$theme: Theme;
13+
$kind: TagKind;
14+
}): StyleObject => ({
15+
color:
16+
$kind === 'primary'
17+
? $theme.colors.contentInversePrimary
18+
: $theme.colors.contentPrimary,
19+
backgroundColor:
20+
$kind === 'primary'
21+
? $theme.colors.backgroundInversePrimary
22+
: $theme.colors.backgroundSecondary,
23+
height: $theme.sizing.scale700,
24+
borderRadius: $theme.borders.radius200,
25+
paddingRight: $theme.sizing.scale200,
26+
paddingLeft: $theme.sizing.scale200,
27+
paddingTop: $theme.sizing.scale300,
28+
paddingBottom: $theme.sizing.scale300,
29+
margin: 0,
30+
}),
31+
},
32+
Text: {
33+
style: ({ $theme }: { $theme: Theme }): StyleObject => ({
34+
...$theme.typography.LabelSmall,
35+
}),
36+
},
37+
} satisfies TagOverrides,
38+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useStyletron } from 'baseui';
2+
import { mergeOverrides } from 'baseui/helpers/overrides';
3+
import { Tag } from 'baseui/tag';
4+
5+
import { overrides } from './selectable-tag.styles';
6+
import { type Props } from './selectable-tag.types';
7+
8+
export default function SelectableTag({ value, onClick, ...tagProps }: Props) {
9+
const [_, theme] = useStyletron();
10+
11+
return (
12+
<Tag
13+
closeable={false}
14+
kind={value ? 'primary' : 'neutral'}
15+
variant="solid"
16+
color={value ? theme.colors.contentPrimary : theme.colors.contentTertiary}
17+
onClick={onClick}
18+
{...tagProps}
19+
overrides={mergeOverrides(overrides.tag, tagProps.overrides)}
20+
/>
21+
);
22+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { type TagProps } from 'baseui/tag';
2+
3+
export type Props = Partial<TagProps> & {
4+
value: boolean;
5+
onClick: () => void;
6+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { type Theme, styled as createStyled } from 'baseui';
2+
import type { FormControlOverrides } from 'baseui/form-control/types';
3+
import { type StyleObject } from 'styletron-react';
4+
5+
export const overrides = {
6+
formControl: {
7+
Label: {
8+
style: ({ $theme }: { $theme: Theme }): StyleObject => ({
9+
...$theme.typography.LabelSmall,
10+
}),
11+
},
12+
ControlContainer: {
13+
style: (): StyleObject => ({
14+
margin: '0px',
15+
}),
16+
},
17+
} satisfies FormControlOverrides,
18+
};
19+
20+
export const styled = {
21+
FormControlContainer: createStyled('div', ({ $theme }) => ({
22+
display: 'flex',
23+
flexDirection: 'column',
24+
gap: $theme.sizing.scale300,
25+
})),
26+
TagsContainer: createStyled('div', ({ $theme }) => ({
27+
display: 'flex',
28+
flexDirection: 'row',
29+
gap: $theme.sizing.scale200,
30+
flexFlow: 'row wrap',
31+
})),
32+
};
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { useCallback, useMemo } from 'react';
2+
3+
import { useStyletron } from 'baseui';
4+
import { FormControl } from 'baseui/form-control';
5+
6+
import SelectableTag from './selectable-tag/selectable-tag';
7+
import { overrides, styled } from './tag-filter.styles';
8+
import { type Props } from './tag-filter.types';
9+
10+
export default function TagFilter<T extends string>({
11+
label,
12+
values,
13+
onChangeValues,
14+
optionsConfig,
15+
hideShowAll,
16+
}: Props<T>) {
17+
const [_, theme] = useStyletron();
18+
19+
const tagKeys = useMemo(
20+
() => Object.keys(optionsConfig) as Array<T>,
21+
[optionsConfig]
22+
);
23+
24+
const areAllValuesSet = useMemo(
25+
() => tagKeys.every((key) => values.includes(key)),
26+
[tagKeys, values]
27+
);
28+
29+
const toggleAllValues = useCallback(
30+
() => onChangeValues(areAllValuesSet ? [] : tagKeys),
31+
[tagKeys, areAllValuesSet, onChangeValues]
32+
);
33+
34+
const onChangeSingleValue = useCallback(
35+
(value: T) =>
36+
onChangeValues(
37+
values.includes(value)
38+
? values.filter((v) => v !== value)
39+
: [...values, value]
40+
),
41+
[values, onChangeValues]
42+
);
43+
44+
return (
45+
<styled.FormControlContainer>
46+
<FormControl label={label} overrides={overrides.formControl}>
47+
<styled.TagsContainer>
48+
{!hideShowAll && (
49+
<SelectableTag
50+
value={areAllValuesSet}
51+
onClick={() => toggleAllValues()}
52+
>
53+
Show all
54+
</SelectableTag>
55+
)}
56+
{tagKeys.map((tagKey) => {
57+
const { label, startEnhancer: StartEnhancer } =
58+
optionsConfig[tagKey];
59+
return (
60+
<SelectableTag
61+
key={tagKey}
62+
value={values.includes(tagKey)}
63+
onClick={() => onChangeSingleValue(tagKey)}
64+
{...(StartEnhancer
65+
? { startEnhancer: () => <StartEnhancer theme={theme} /> }
66+
: {})}
67+
>
68+
{label}
69+
</SelectableTag>
70+
);
71+
})}
72+
</styled.TagsContainer>
73+
</FormControl>
74+
</styled.FormControlContainer>
75+
);
76+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { type Theme } from 'baseui';
2+
3+
export type TagFilterOptionConfig = {
4+
label: string;
5+
startEnhancer?: React.ComponentType<{ theme: Theme }>;
6+
};
7+
8+
export type Props<T extends string> = {
9+
label: string;
10+
values: Array<T>;
11+
onChangeValues: (newValues: Array<T>) => void;
12+
optionsConfig: Record<T, TagFilterOptionConfig>;
13+
hideShowAll?: boolean;
14+
};

0 commit comments

Comments
 (0)