Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/assets/src/scss/inputs/_dropdown.scss
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ $item-gap: calculateRem(4px);
}
}

&--multi {
&--multi,
&--filter {
#{$self} {
&__widget.ids-input:hover:has(.ids-chip__delete:hover) {
border-color: var(--ids-input-default-border-color, #{$color-neutral-80});
Expand Down
132 changes: 132 additions & 0 deletions packages/assets/src/scss/inputs/_filter-dropdown.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
@use '../functions' as *;
@use '../variables' as *;

$trigger-height: calculateRem(40px);
$trigger-height-compact: calculateRem(32px);

.ids-dropdown--filter {
$self: '.ids-dropdown';

display: inline-block;

#{$self}__trigger {
display: inline-flex;
align-items: center;
gap: calculateRem(8px);
height: $trigger-height;
padding: 0 calculateRem(12px) 0 calculateRem(16px);
border: calculateRem(1px) solid $color-neutral-80;
border-radius: $border-radius-medium;
background-color: $color-neutral-10;
color: $color-neutral-240;
font-family: inherit;
font-size: $text-font-size-m;
font-weight: $text-font-weight-semi;
white-space: nowrap;
cursor: pointer;

&:hover {
border-color: $color-neutral-120;
}

&:focus {
outline: none;
}

&:focus-visible {
box-shadow: $box-shadow-focus-primary;
}

&:disabled {
border-color: $color-neutral-50;
color: $color-neutral-160;
cursor: default;
}

.ids-icon {
fill: currentColor;
}
}

#{$self}__trigger-icon,
#{$self}__chevron {
display: flex;
}

#{$self}__value {
font-weight: $text-font-weight-normal;
}

#{$self}__counter {
display: inline-flex;
align-items: center;
height: calculateRem(16px);
padding: 0 calculateRem(8px);
border: calculateRem(1px) solid $color-neutral-50;
border-radius: calculateRem(4px);
background-color: $color-neutral-30;
color: $color-neutral-200;
font-size: $text-font-size-xs;
font-weight: $text-font-weight-normal;
}

#{$self}__footer {
padding: 0 calculateRem(8px) calculateRem(8px);
}

#{$self}__divider {
margin: 0 0 calculateRem(8px);
border: 0;
border-top: calculateRem(1px) solid $color-neutral-70;
}

#{$self}__footer .ids-btn {
width: 100%;
}

&.ids-dropdown--open {
#{$self}__trigger {
border-color: $color-primary-100;
}

#{$self}__chevron .ids-icon {
transform: rotate(180deg);
}
}

&.ids-dropdown--selected {
#{$self}__trigger {
border-color: $color-info-30;
background-color: $color-info-0;
}
}

&.ids-dropdown--filter-dashboard {
#{$self}__trigger {
height: $trigger-height-compact;
padding: 0 calculateRem(8px) 0 calculateRem(12px);
}
}

&.ids-dropdown--filter-more-filters {
#{$self}__trigger {
padding: 0 calculateRem(12px);
}
}

&.ids-dropdown--filter-more-filters-small {
#{$self}__trigger {
padding: 0 calculateRem(10px);
}
}

&.ids-dropdown--filter-more-filters,
&.ids-dropdown--filter-more-filters-small {
&.ids-dropdown--open {
#{$self}__trigger {
border-color: $color-neutral-120;
background-color: $color-neutral-50;
}
}
}
}
1 change: 1 addition & 0 deletions packages/assets/src/scss/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
@use 'inputs/alt-radio';
@use 'inputs/checkbox';
@use 'inputs/dropdown';
@use 'inputs/filter-dropdown';
@use 'inputs/input-text';
@use 'inputs/radio-button';
@use 'inputs/toggle';
Expand Down
4 changes: 4 additions & 0 deletions packages/components/assets/ibexa_design_system.en.xliff
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
<source>Toggle password visibility</source>
<target>Toggle password visibility</target>
</trans-unit>
<trans-unit id="ids.filter_dropdown.clear.label" resname="ids.filter_dropdown.clear.label">
<source>Clear</source>
<target>Clear</target>
</trans-unit>
<trans-unit id="ibexa.chip.delete-btn.label" resname="ibexa.chip.delete-btn.label">
<source>Delete</source>
<target>Delete</target>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from 'storybook/test';
import { action } from 'storybook/actions';

import { FilterDropdownStateful, FilterDropdownType } from '.';
import { DropdownDecorator } from '@ids-sb-decorators/DropdownDecorator';
import { generateItemsArray } from '@ids-sb-utils/generators';

const DEFAULT_ITEMS_LENGTH = 6;
const WRAPPER_HEIGHT = 420;
const openPanel = async ({ canvasElement }: { canvasElement: HTMLElement }) => {
const canvas = within(canvasElement);

await userEvent.click(canvas.getByRole('button', { expanded: false }));
};

const meta: Meta<typeof FilterDropdownStateful> = {
component: FilterDropdownStateful,
tags: ['autodocs', 'foundation'],
args: {
items: generateItemsArray(DEFAULT_ITEMS_LENGTH),
label: 'Filter',
name: 'filter',
onChange: action('on-change'),
},
argTypes: {
onChange: { control: { disable: true } },
},
decorators: [DropdownDecorator],
parameters: {
wrapperHeight: WRAPPER_HEIGHT,
},
};

export default meta;

type Story = StoryObj<typeof FilterDropdownStateful>;

export const Default: Story = {
name: 'Default',
};

export const DefaultOpenedMenu: Story = {
name: 'Default (Opened Menu)',
tags: ['!dev'],
play: openPanel,
};

export const DefaultSelected: Story = {
name: 'Default (Selected)',
args: {
value: ['2'],
},
};

export const Dashboard: Story = {
name: 'Dashboard',
args: {
type: FilterDropdownType.Dashboard,
},
};

export const DashboardOpenedMenu: Story = {
name: 'Dashboard (Opened Menu)',
tags: ['!dev'],
args: {
type: FilterDropdownType.Dashboard,
},
play: openPanel,
};

export const DashboardSelected: Story = {
name: 'Dashboard (Selected)',
args: {
type: FilterDropdownType.Dashboard,
value: ['2'],
},
};

export const MoreFilters: Story = {
name: 'More Filters',
args: {
label: 'More filters',
type: FilterDropdownType.MoreFilters,
},
};

export const MoreFiltersOpenedMenu: Story = {
name: 'More Filters (Opened Menu)',
tags: ['!dev'],
args: {
label: 'More filters',
type: FilterDropdownType.MoreFilters,
},
play: openPanel,
};

export const MoreFiltersSmall: Story = {
name: 'More Filters Small',
args: {
label: 'More filters',
type: FilterDropdownType.MoreFiltersSmall,
},
};

export const Disabled: Story = {
name: 'Disabled',
args: {
disabled: true,
},
};

export const WithoutSearch: Story = {
name: 'Without Search (Opened Menu)',
tags: ['!dev'],
args: {
hasSearch: false,
},
play: openPanel,
};

export const EmptySearch: Story = {
name: 'Empty Search (Opened Menu)',
tags: ['!dev'],
play: async ({ canvasElement }) => {
await openPanel({ canvasElement });

await userEvent.type(within(canvasElement).getByRole('textbox'), 'zzz');
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from 'storybook/test';

import { FilterDropdownAction, FilterDropdownStateful, FilterDropdownType } from '.';

const meta: Meta<typeof FilterDropdownStateful> = {
component: FilterDropdownStateful,
tags: ['!dev'],
args: {
items: [
{ id: 'value1', label: 'Item 1' },
{ id: 'value2', label: 'Item 2' },
{ id: 'value3', label: 'Item 3' },
],
label: 'Filter',
name: 'filter',
onChange: fn(),
},
};

export default meta;

type Story = StoryObj<typeof FilterDropdownStateful>;

export const Default: Story = {
name: 'Default',
play: async ({ args, canvasElement, step }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole('button', { name: 'Filter' });

await step('Checking an item reports it and shows the counter', async () => {
await userEvent.click(trigger);
await userEvent.click(canvas.getByText('Item 1', { selector: '.ids-dropdown__item-label' }));

await expect(args.onChange).toHaveBeenLastCalledWith(['value1'], 'value1', FilterDropdownAction.Check);
await expect(canvasElement.querySelector('.ids-dropdown__counter')).toHaveTextContent('1');
await expect(canvasElement.querySelector('.ids-dropdown__items-container')).not.toBeNull();
});

await step('Unchecking the item reports it', async () => {
await userEvent.click(canvas.getByText('Item 1', { selector: '.ids-dropdown__item-label' }));

await expect(args.onChange).toHaveBeenLastCalledWith([], 'value1', FilterDropdownAction.Uncheck);
await expect(canvasElement.querySelector('.ids-dropdown__counter')).toBeNull();
});

await step('Clear reports an empty value, keeps the panel open and disables itself', async () => {
await userEvent.click(canvas.getByText('Item 2', { selector: '.ids-dropdown__item-label' }));
await userEvent.click(canvas.getByRole('button', { name: 'Clear' }));

await expect(args.onChange).toHaveBeenLastCalledWith([], '', FilterDropdownAction.Clear);
await expect(canvasElement.querySelector('.ids-dropdown__items-container')).not.toBeNull();
await expect(canvas.getByRole('button', { name: 'Clear' })).toBeDisabled();
});

await step('A search with no match shows the no-results row', async () => {
await userEvent.type(canvas.getByRole('textbox'), 'zzz');

await expect(canvasElement.querySelector('.ids-dropdown__no-results')).not.toBeNull();
});

await step('Escape closes the panel and returns focus to the trigger', async () => {
await userEvent.keyboard('{Escape}');

await expect(canvasElement.querySelector('.ids-dropdown__items-container')).toBeNull();
await expect(document.activeElement).toBe(trigger);
});
},
};

export const DashboardValue: Story = {
name: 'Dashboard value',
args: {
type: FilterDropdownType.Dashboard,
value: ['value2'],
},
play: async ({ canvasElement }) => {
await expect(canvasElement.querySelector('.ids-dropdown__value')).toHaveTextContent('Item 2');
await expect(canvasElement.querySelector('.ids-dropdown__trigger-label')).toHaveTextContent('Filter:');
await expect(canvasElement.querySelector('.ids-dropdown__counter')).toBeNull();
},
};

export const WithoutSearchAndDisabled: Story = {
name: 'Without search and disabled',
args: {
disabled: true,
hasSearch: false,
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);

await step('A disabled trigger does not open the panel', async () => {
await userEvent.click(canvas.getByRole('button', { name: 'Filter' }));

await expect(canvasElement.querySelector('.ids-dropdown__items-container')).toBeNull();
});
},
};
Loading
Loading