Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ItemSelector component #33

Merged
merged 7 commits into from
Apr 18, 2023
Merged
Show file tree
Hide file tree
Changes from 6 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
82 changes: 82 additions & 0 deletions src/components/ItemSelector/ItemSelector.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
@use '~@gravity-ui/uikit/styles/mixins';
@use '../variables';

$block: '.#{variables.$ns}item-selector';

#{$block} {
--yc-list-margin: 16px;
--yc-list-height: 196px;

display: flex;
width: 100%;
min-height: 200px;

&__list {
flex: 0 0 50%;
padding-top: 8px;

&:not(:last-child) {
border-right: 1px solid var(--yc-color-line-generic);
}
}

&__list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding: 0 var(--yc-list-margin);
min-height: 24px;
}

&__list-title {
@include mixins.text-accent;
}

&__item {
display: flex;
align-items: center;
width: 100%;
height: 100%;

&_active {
#{$block}__item-select {
display: block;
}
}
}

&__item-select {
display: none;
}

&__item-text {
overflow: hidden;
text-overflow: ellipsis;
margin-right: auto;
}

&__value-item {
display: flex;
align-items: center;
width: 100%;
overflow: hidden;

&_active {
#{$block}__value-item-remove {
display: block;
}
}
}

&__value-item-text {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

&__value-item-remove {
display: none;
}
}
184 changes: 184 additions & 0 deletions src/components/ItemSelector/ItemSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import React from 'react';
import {List, ListProps, Button, Icon} from '@gravity-ui/uikit';
import {Xmark} from '@gravity-ui/icons';

import i18n from './i18n';
import {block} from '../utils/cn';

import './ItemSelector.scss';

const b = block('item-selector');

function getItemIdDefault<T>(item: T) {
return `${item}`;
}

export interface ItemSelectorProps<T> {
selectorTitle?: string;

items: T[];
value: string[];
hideSelected?: boolean;
hideSelectAllButton?: boolean;

onUpdate: (value: string[]) => void;
getItemId: (item: T) => string;

renderItemValue?: (item: T) => React.ReactNode;
renderItem?: ListProps<T>['renderItem'];
filterItem?: ListProps<T>['filterItem'];
}

export class ItemSelector<T> extends React.Component<ItemSelectorProps<T>> {
static defaultProps = {
hideSelected: true,
selectorTitle: '',
getItemId: getItemIdDefault,
};
renderItemTitle = (item: T) => {
const {renderItemValue, getItemId} = this.props;
if (renderItemValue) {
return renderItemValue(item);
}
return getItemId(item);
};
renderItem = (item: T, active: boolean) => (
<div className={b('item', {active})}>
<span className={b('item-text')}>{this.renderItemTitle(item)}</span>
<Button
view="flat-secondary"
size="s"
className={b('item-select')}
onClick={this.onAddItem.bind(this, item)}
>
{i18n('button_select')}
</Button>
</div>
);
filterItem = (filter: string) => (item: T) => {
const {getItemId} = this.props;
return getItemId(item).includes(filter);
};
renderValueItem = (item: T, active: boolean) => (
<div className={b('value-item', {active})}>
<span className={b('value-item-text')}>{this.renderItemTitle(item)}</span>
<Button
view="flat-secondary"
size="s"
className={b('value-item-remove')}
onClick={() => this.onRemoveItem(item)}
>
<Icon data={Xmark} size={10} />
</Button>
</div>
);
getActualItems() {
const {items, value, hideSelected, getItemId} = this.props;
const actualItems = [];
const selectedItems = new Array(value.length);
const usedItems = new Map(value.map((id, index) => [id, index]));
for (const item of items) {
const selected = usedItems.get(getItemId(item));
if (selected !== undefined) {
selectedItems[selected] = item;
}
if (!hideSelected || selected === undefined) {
actualItems.push(item);
}
}
return [actualItems, selectedItems];
}
onAddItem = (item: T) => {
const {getItemId, value} = this.props;
const itemId = getItemId(item);
const usedItems = new Set(value);
const newValue = usedItems.has(itemId) ? value : [...value, itemId];
setTimeout(() => {
this.onUpdate(newValue);
}, 0);
};
onRemoveItem = (item: T) => {
const {value, getItemId} = this.props;
const itemId = getItemId(item);
const newValue = value.filter((id) => id !== itemId);
setTimeout(() => {
this.onUpdate(newValue);
}, 0);
};
onErase = () => {
this.onUpdate([]);
};
onSelectAll = () => {
const {items, getItemId} = this.props;
const value = items.map(getItemId);
this.onUpdate(value);
};
onMoveItem = ({oldIndex, newIndex}: {oldIndex: number; newIndex: number}) => {
if (oldIndex !== newIndex) {
const value = this.props.value.slice();
this.onUpdate(List.moveListElement(value, oldIndex, newIndex));
}
};
onUpdate = (value: string[]) => {
this.props.onUpdate(value);
};

render() {
const {
value,
selectorTitle,
renderItem = this.renderItem,
filterItem = this.filterItem,
hideSelectAllButton,
} = this.props;
const [items, selected] = this.getActualItems();
return (
<div className={b()}>
<div className={b('list')}>
<div className={b('list-header')}>
<span className={b('list-title')}>{selectorTitle}</span>
{!hideSelectAllButton && (
<Button
view="flat"
size="s"
disabled={items.length === 0}
onClick={this.onSelectAll}
>
{i18n('button_select-all')}
</Button>
)}
</div>
<List
items={items}
renderItem={renderItem}
filterItem={filterItem}
filterPlaceholder={i18n('placeholder_search')}
/>
</div>
<div className={b('list')}>
<div className={b('list-header')}>
<span className={b('list-title')}>
{`${i18n('label_selected')}: ${value.length}`}
</span>
<Button
view="flat"
size="s"
disabled={value.length === 0}
onClick={this.onErase}
>
{i18n('button_deselect-all')}
</Button>
</div>
<List
items={selected}
renderItem={this.renderValueItem}
filterItem={filterItem}
filterPlaceholder={i18n('placeholder_search')}
sortable={true}
onSortEnd={this.onMoveItem}
/>
</div>
</div>
);
}
}
45 changes: 45 additions & 0 deletions src/components/ItemSelector/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
## ItemSelector

A component that allows you to form a subset of a list (e.g., a subset of columns).

### PropTypes

| Property | Type | Required | Default | | Description |
| :-------------- | :-------------- | :------: | :------------------------------------------------------- | :-- | :----------------------------------------------------------------------------------------------------------------------------------- |
| [items](#items) | `Array` | yes | | | Item list. |
| value | `Array<string>` | yes | | | A subset of the `id` list of items. |
| onUpdate | `function` | yes | | | Filter change handler (when using external sorting). `(value: Array) => void` |
| hideSelected | `boolean` | | true | | When this flag is checked, it hides already selected items from the main list. |
| selectorTitle | `string` | | '' | | Component title. |
| getItemId | `function` | | `(item: any) => string` | | A callback that returns the `id` of an item if the item is complex. `(item: any) => string` |
| renderItem | `function` | | `(item) => getItemId(item)` | | Render element in the main list ([List component documentation](https://github.com/gravity-ui/uikit/tree/main/src/components/List)). |
| filterItem | `function` | | `(filter) => (item) => getItemId(item).includes(filter)` | | Filtering items in lists ([List component documentation](https://github.com/gravity-ui/uikit/tree/main/src/components/List)). |
| renderItemValue | `function` | | `(item) => getItemId(item)` | | Render element view in lists. |

#### Items

The item can be a string or an any object (but must be `truly`).

The `getItemId` must be specified if the items in the main list are objects and not strings.
The `renderItem` and `filterItem` can be specified if custom display and filtering are required.

```jsx
<ItemSelector
selectorTitle="Columns"
onUpdate={(value) => {
this.setState({value});
}}
items={[
{
name: 'id',
type: 'Uint32',
},
{
name: 'series',
type: 'Utf8',
},
]}
value={value}
getItemId={(item) => item.name}
/>
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React, {useState} from 'react';
import {Meta, Story} from '@storybook/react';
import {ItemSelector} from '../ItemSelector';

export default {
title: 'Components/AdaptiveTabs',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why adaptive tabs?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

component: ItemSelector,
} as Meta;

const items = [
{
name: 'id',
type: 'Uint32',
},
{
name: 'series',
type: 'Utf8',
},
{
name: 'episodes',
type: 'Utf8',
},
{
name: 'actors',
type: 'Utf8',
},
{
name: 'director',
type: 'Utf8',
},
{
name: 'music',
type: 'Utf8',
},
{
name: 'something',
type: 'Utf8',
},
{
name: 'dancing',
type: 'Utf8',
},
{
name: 'shooting',
type: 'Utf8',
},
];

const DefaultTemplate: Story = (args) => {
const [value, setValue] = useState<string[]>([]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use story args update instead, like

import {useArgs} from '@storybook/client-api';

const Template: ComponentStory<typeof ItemSelector> = (args) => {
  const [, setStoryArgs] = useArgs();

  return <ItemSelector {...args} onUpdate={(value) => setStoryArg(value)}/>
}

return (
<ItemSelector
{...args}
items={items}
value={value}
onUpdate={(val) => setValue(val)}
getItemId={(item) => item.name}
/>
);
};
export const Default = DefaultTemplate.bind({});
7 changes: 7 additions & 0 deletions src/components/ItemSelector/i18n/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"button_select-all": "Select all",
"button_deselect-all": "Clear",
"button_select": "Select",
"label_selected": "Selected",
"placeholder_search": "Search"
}
6 changes: 6 additions & 0 deletions src/components/ItemSelector/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {registerKeyset} from '../../utils/registerKeyset';
import en from './en.json';
import ru from './ru.json';

const COMPONENT = 'ItemSelector';
export default registerKeyset({en, ru}, COMPONENT);
7 changes: 7 additions & 0 deletions src/components/ItemSelector/i18n/ru.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"button_select-all": "Выбрать все",
"button_deselect-all": "Очистить",
"button_select": "Выбрать",
"label_selected": "Выбрано",
"placeholder_search": "Поиск"
}