Skip to content

Commit

Permalink
Merge branch 'dev' of github.com-rtivital:mantinedev/mantine
Browse files Browse the repository at this point in the history
  • Loading branch information
rtivital committed Mar 28, 2023
2 parents e23ae01 + 004b75a commit 8d8d65b
Show file tree
Hide file tree
Showing 21 changed files with 449 additions and 40 deletions.
16 changes: 16 additions & 0 deletions docs/src/docs/core/MultiSelect.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,22 @@ Apart from `itemComponent` you can customize appearance of label by providing `v

<Demo data={MultiSelectDemos.maxSelectedValues} />

## Disable selected item filtering

<Demo data={MultiSelectDemos.disableSelectedItemFiltering} />

When used along `filter`, be aware that the second parameter `selected` will always be `false`.

```tsx
<MultiSelect
disableSelectedItemFiltering
filter={(value, selected, item) => {
console.log(selected); // false
}}
searchable
/>
```

## Dropdown position

By default, dropdown is placed below the input and when there is not enough space, it flips to be above the input.
Expand Down
2 changes: 1 addition & 1 deletion docs/src/docs/form/use-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ form.insertListItem('fruits', { name: 'Orange', available: true }, 1);
form.removeListItem('fruits', 1);

// Swaps two items of the list at the specified path.
// If no element exists at the `from` or `to` index, the list doesn't change.
// You should make sure that there are elements at at the `from` and `to` index.
form.reorderListItem('fruits', { from: 1, to: 0 });
```

Expand Down
8 changes: 4 additions & 4 deletions src/mantine-core/src/Autocomplete/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,11 +221,7 @@ export const Autocomplete = forwardRef<HTMLInputElement, AutocompleteProps>((pro
<SelectPopover.Target>
<div
className={classes.wrapper}
role="combobox"
aria-haspopup="listbox"
aria-owns={shouldRenderDropdown ? `${inputProps.id}-items` : null}
aria-controls={inputProps.id}
aria-expanded={shouldRenderDropdown}
onMouseLeave={() => setHovered(-1)}
tabIndex={-1}
>
Expand All @@ -251,6 +247,10 @@ export const Autocomplete = forwardRef<HTMLInputElement, AutocompleteProps>((pro
onClick={handleInputClick}
onCompositionStart={() => setIMEOpen(true)}
onCompositionEnd={() => setIMEOpen(false)}
role="combobox"
aria-haspopup="listbox"
aria-owns={shouldRenderDropdown ? `${inputProps.id}-items` : null}
aria-expanded={shouldRenderDropdown}
aria-autocomplete="list"
aria-controls={shouldRenderDropdown ? `${inputProps.id}-items` : null}
aria-activedescendant={hovered >= 0 ? `${inputProps.id}-${hovered}` : null}
Expand Down
41 changes: 41 additions & 0 deletions src/mantine-core/src/MultiSelect/filter-data/filter-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,47 @@ describe('@mantine/core/MultiSelect/filter-data', () => {
).toStrictEqual(data.filter((item) => item.value !== 'vue' && item.value !== 'ng'));
});

it('keeps selected items in input that is not searchable and has disabled selected item filtering', () => {
expect(
filterData({
...baseOptions,
disableSelectedItemFiltering: true,
searchable: false,
searchValue: '',
value: ['vue', 'ng'],
})
).toStrictEqual(data);
});

it('keeps selected items in input that is searchable and has disabled selected item filtering', () => {
expect(
filterData({
...baseOptions,
disableSelectedItemFiltering: true,
searchable: true,
searchValue: '',
value: ['vue', 'ng'],
})
).toStrictEqual(data);
});

it('sends filter selected parameter as false when input has disabled selected item filtering', () => {
const spy = jest.fn();

filterData({
...baseOptions,
disableSelectedItemFiltering: true,
filter: spy,
searchable: true,
searchValue: '',
value: ['vue', 'ng'],
});

spy.mock.calls.forEach((call) => {
expect(call[1]).toStrictEqual(false);
});
});

it('filters items with given filter function', () => {
const filter = (searchValue: string, selected: boolean, item: SelectItem) =>
item.name.includes(searchValue);
Expand Down
3 changes: 2 additions & 1 deletion src/mantine-core/src/MultiSelect/filter-data/filter-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export function filterData({
if (
filter(
searchValue,
value.some((val) => val === data[i].value && !data[i].disabled),
!disableSelectedItemFiltering &&
value.some((val) => val === data[i].value && !data[i].disabled),
data[i]
)
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react';
import { MultiSelect } from '@mantine/core';
import { MantineDemo } from '@mantine/ds';
import { data } from './_data';

const code = `
import { MultiSelect } from '@mantine/core';
function Demo() {
return (
<MultiSelect
data={['React', 'Angular', 'Svelte', 'Vue', 'Riot', 'Next.js', 'Blitz.js']}
disableSelectedItemFiltering
label="Your favorite frameworks/libraries"
nothingFound="Nothing found"
placeholder="Pick all that you like"
searchable
/>
);
}
`;

function Demo() {
return (
<MultiSelect
data={data}
disableSelectedItemFiltering
label="Your favorite frameworks/libraries"
maw={400}
mx="auto"
nothingFound="Nothing found"
placeholder="Pick all that you like"
searchable
/>
);
}

export const disableSelectedItemFiltering: MantineDemo = {
type: 'demo',
code,
component: Demo,
};
1 change: 1 addition & 0 deletions src/mantine-demos/src/demos/core/MultiSelect/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ export { icon } from './MultiSelect.demo.icon';
export { rightSection } from './MultiSelect.demo.rightSection';
export { scrollbars } from './MultiSelect.demo.scrollbars';
export { maxSelectedValues } from './MultiSelect.demo.maxSelectedValues';
export { disableSelectedItemFiltering } from './MultiSelect.demo.disableSelectedItemFiltering';
export { readOnly } from './MultiSelect.demo.readOnly';
export { hoverOnSearchChange } from './MultiSelect.demo.hoverOnSearchChange';
1 change: 0 additions & 1 deletion src/mantine-form/src/clear-list-state/index.ts

This file was deleted.

117 changes: 117 additions & 0 deletions src/mantine-form/src/lists/change-error-indices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { changeErrorIndices } from './change-error-indices';

const TEST_ERRORS = {
name: 'name-error',

// single level of nesting
'fruits.0.name': 'fruit-error-1',
'fruits.0.available': 'fruit-error-2',
'fruits.4.available': 'fruit-error-3',
'fruits.15.inner.name': 'fruit-error-4',
'fruits.15.inner.0.name': 'fruit-error-5',

// multiple levels of nesting
'nested.0.inner.1.name': 'nested-error-1',
'nested.0.inner.2.name': 'nested-error-2',
'nested.2.inner.2.name': 'keep-nested-error-1',
'nested.3.inner.0.name': 'keep-nested-error-2',
'nested.5.inner.1.check': 'keep-nested-error-2',
'nested.0.inner.2.check': 'nested-error-3',
'nested.0.inner.5.check': 'nested-error-4',
};

describe('@mantine/form/change-error-indices', () => {
it('increments error indices', () => {
expect(changeErrorIndices('fruits', 4, TEST_ERRORS, 1)).toStrictEqual({
name: 'name-error',
// Errors with index lower than the given one don't change
'fruits.0.name': 'fruit-error-1',
'fruits.0.available': 'fruit-error-2',
// Increment everything else
'fruits.5.available': 'fruit-error-3',
'fruits.16.inner.name': 'fruit-error-4',
'fruits.16.inner.0.name': 'fruit-error-5',
// Ignore non-matching paths
'nested.0.inner.1.name': 'nested-error-1',
'nested.0.inner.2.name': 'nested-error-2',
'nested.2.inner.2.name': 'keep-nested-error-1',
'nested.3.inner.0.name': 'keep-nested-error-2',
'nested.5.inner.1.check': 'keep-nested-error-2',
'nested.0.inner.2.check': 'nested-error-3',
'nested.0.inner.5.check': 'nested-error-4',
});
});

it('decrements error indices and removes errors for the removed element', () => {
expect(changeErrorIndices('fruits', 4, TEST_ERRORS, -1)).toStrictEqual({
name: 'name-error',
// Errors with index lower than the given one don't change
'fruits.0.name': 'fruit-error-1',
'fruits.0.available': 'fruit-error-2',
// Remove the error with the given index
// Decrement everything else
'fruits.14.inner.name': 'fruit-error-4',
'fruits.14.inner.0.name': 'fruit-error-5',
// Ignore non-matching paths
'nested.0.inner.1.name': 'nested-error-1',
'nested.0.inner.2.name': 'nested-error-2',
'nested.2.inner.2.name': 'keep-nested-error-1',
'nested.3.inner.0.name': 'keep-nested-error-2',
'nested.5.inner.1.check': 'keep-nested-error-2',
'nested.0.inner.2.check': 'nested-error-3',
'nested.0.inner.5.check': 'nested-error-4',
});
});

it('increments deeply nested errors', () => {
expect(changeErrorIndices('nested.0.inner', 2, TEST_ERRORS, 1)).toStrictEqual({
name: 'name-error',
'fruits.0.name': 'fruit-error-1',
'fruits.0.available': 'fruit-error-2',
'fruits.4.available': 'fruit-error-3',
'fruits.15.inner.name': 'fruit-error-4',
'fruits.15.inner.0.name': 'fruit-error-5',
'nested.0.inner.1.name': 'nested-error-1',
'nested.0.inner.3.name': 'nested-error-2',
'nested.2.inner.2.name': 'keep-nested-error-1',
'nested.3.inner.0.name': 'keep-nested-error-2',
'nested.5.inner.1.check': 'keep-nested-error-2',
'nested.0.inner.3.check': 'nested-error-3',
'nested.0.inner.6.check': 'nested-error-4',
});
});

it('decrements deeply nested errors and removes errors for the removed element', () => {
expect(changeErrorIndices('nested.0.inner', 2, TEST_ERRORS, -1)).toStrictEqual({
name: 'name-error',
'fruits.0.name': 'fruit-error-1',
'fruits.0.available': 'fruit-error-2',
'fruits.4.available': 'fruit-error-3',
'fruits.15.inner.name': 'fruit-error-4',
'fruits.15.inner.0.name': 'fruit-error-5',
'nested.0.inner.1.name': 'nested-error-1',
'nested.2.inner.2.name': 'keep-nested-error-1',
'nested.3.inner.0.name': 'keep-nested-error-2',
'nested.5.inner.1.check': 'keep-nested-error-2',
'nested.0.inner.4.check': 'nested-error-4',
});
});

describe('returns unchanged object', () => {
it('if index is undefined', () => {
expect(changeErrorIndices('fruits', undefined, TEST_ERRORS, 1)).toStrictEqual(TEST_ERRORS);
});

it('if path is not a string', () => {
expect(changeErrorIndices(1, 1, TEST_ERRORS, 1)).toStrictEqual(TEST_ERRORS);
});

it('if path does not exist', () => {
expect(changeErrorIndices('does-not-exist', 1, TEST_ERRORS, 1)).toStrictEqual(TEST_ERRORS);
});

it('if index is bigger than any error index', () => {
expect(changeErrorIndices('fruits', 100, TEST_ERRORS, 1)).toStrictEqual(TEST_ERRORS);
});
});
});
59 changes: 59 additions & 0 deletions src/mantine-form/src/lists/change-error-indices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { clearListState } from './clear-list-state';

/**
* Gets the part of the key after the path which can be an index
*/
function getIndexFromKeyAfterPath(key: string, path: string): number {
const split = key.substring(path.length + 1).split('.')[0];
return parseInt(split, 10);
}

/**
* Changes the indices of every error that is after the given `index` with the given `change` at the given `path`.
* This requires that the errors are in the format of `path.index` and that the index is a number.
*/
export function changeErrorIndices<T extends Record<PropertyKey, any>>(
path: PropertyKey,
index: number,
errors: T,
change: 1 | -1
): T {
if (index === undefined) {
return errors;
}
const pathString = `${String(path)}`;
let clearedErrors = errors;
// Remove all errors if the corresponding item was removed
if (change === -1) {
clearedErrors = clearListState(`${pathString}.${index}`, clearedErrors);
}

const cloned = { ...clearedErrors };
const changedKeys = new Set<string>();
Object.entries(clearedErrors)
.filter(([key]) => {
if (!key.startsWith(`${pathString}.`)) {
return false;
}
const currIndex = getIndexFromKeyAfterPath(key, pathString);
if (Number.isNaN(currIndex)) {
return false;
}
return currIndex >= index;
})
.forEach(([key, value]) => {
const currIndex = getIndexFromKeyAfterPath(key, pathString);

const newKey: keyof T = key.replace(
`${pathString}.${currIndex}`,
`${pathString}.${currIndex + change}`
);
cloned[newKey] = value;
changedKeys.add(newKey);
if (!changedKeys.has(key)) {
delete cloned[key];
}
});

return cloned;
}
3 changes: 3 additions & 0 deletions src/mantine-form/src/lists/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { clearListState } from './clear-list-state';
export { changeErrorIndices } from './change-error-indices';
export { reorderErrors } from './reorder-errors';
18 changes: 18 additions & 0 deletions src/mantine-form/src/lists/reorder-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { reorderErrors } from './reorder-errors';

describe('@mantine/form/reorder-errors', () => {
it('reorders errors at given path', () => {
expect(reorderErrors('a', { from: 2, to: 0 }, { 'a.0': true })).toStrictEqual({
'a.2': true,
});
expect(reorderErrors('a', { from: 2, to: 0 }, { 'a.0': true, 'a.2': 'Error' })).toStrictEqual({
'a.0': 'Error',
'a.2': true,
});
});

it('returns unchanged object if path does not exist', () => {
const errors = { 'a.0': true };
expect(reorderErrors('c', { from: 1, to: 2 }, errors)).toStrictEqual(errors);
});
});
30 changes: 30 additions & 0 deletions src/mantine-form/src/lists/reorder-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ReorderPayload } from '../types';

export function reorderErrors<T>(path: unknown, { from, to }: ReorderPayload, errors: T): T {
const oldKeyStart = `${path}.${from}`;
const newKeyStart = `${path}.${to}`;

const clone = { ...errors };
Object.keys(errors).every((key) => {
let oldKey;
let newKey;
if (key.startsWith(oldKeyStart)) {
oldKey = key;
newKey = key.replace(oldKeyStart, newKeyStart);
}
if (key.startsWith(newKeyStart)) {
oldKey = key.replace(newKeyStart, oldKeyStart);
newKey = key;
}
if (oldKey && newKey) {
const value1 = clone[oldKey];
const value2 = clone[newKey];
value2 === undefined ? delete clone[oldKey] : (clone[oldKey] = value2);
value1 === undefined ? delete clone[newKey] : (clone[newKey] = value1);
return false;
}
return true;
});

return clone;
}
Loading

0 comments on commit 8d8d65b

Please sign in to comment.