添加关联勾选模式

This commit is contained in:
2betop 2020-05-29 13:18:27 +08:00
parent da91ffae0f
commit 9906a615d8
14 changed files with 778 additions and 27 deletions

View File

@ -426,6 +426,110 @@ export default {
]
}
]
},
,
{
label: '关联选择模式',
type: 'transfer',
name: 'b',
sortable: true,
searchable: true,
deferApi: '/api/mock2/form/deferOptions?label=${label}',
selectMode: 'associated',
leftMode: 'tree',
leftOptions: [
{
label: '法师',
children: [
{
label: '诸葛亮',
value: 'zhugeliang'
}
]
},
{
label: '战士',
children: [
{
label: '曹操',
value: 'caocao'
},
{
label: '钟无艳',
value: 'zhongwuyan'
}
]
},
{
label: '打野',
children: [
{
label: '李白',
value: 'libai'
},
{
label: '韩信',
value: 'hanxin'
},
{
label: '云中君',
value: 'yunzhongjun'
}
]
}
],
options: [
{
ref: 'zhugeliang',
children: [
{
label: 'A',
value: 'a'
}
]
},
{
ref: 'caocao',
children: [
{
label: 'B',
value: 'b'
},
{
label: 'C',
value: 'c'
}
]
},
{
ref: 'zhongwuyan',
children: [
{
label: 'D',
value: 'd'
},
{
label: 'E',
value: 'e'
}
]
},
{
ref: 'libai',
defer: true
},
{
ref: 'hanxin',
defer: true
},
{
ref: 'yunzhongjun',
defer: true
}
]
}
]
}

View File

@ -221,7 +221,7 @@
}
&--sm.#{$ns}Checkbox--full {
input {
input[type='checkbox'] {
&:checked + i {
&:before {
width: $Checkbox--sm--full-inner-size;
@ -312,7 +312,8 @@
}
}
.#{$ns}ListCheckboxes {
.#{$ns}ListCheckboxes,
.#{$ns}ListRadios {
&-group:not(:first-child) > &-itemLabel {
border-top: px2rem(1px) solid $ListMenu-divider-color;
}
@ -362,6 +363,15 @@
}
}
.#{$ns}ListRadios {
&-item {
&.is-active {
color: $Form-select-menu-onActive-color;
background-color: $Form-select-menu-onActive-bg;
}
}
}
.#{$ns}TableCheckboxes {
.#{$ns}Table-content {
border-top: $Table-borderWidth solid $Table-borderColor;
@ -395,7 +405,8 @@
}
}
.#{$ns}TreeCheckboxes {
.#{$ns}TreeCheckboxes,
.#{$ns}TreeRadios {
.#{$ns}Table-expandBtn {
color: $icon-color;
margin-right: 5px;
@ -420,10 +431,9 @@
&-item {
position: relative;
&.is-expanded > .#{$ns}TreeCheckboxes-sublist {
display: block;
}
}
&-item.is-expanded > &-sublist {
display: block;
}
&-item:not(:last-child) > &-sublist:before {
@ -463,6 +473,11 @@
background-color: $Tree-item-onHover-bg;
}
&.is-active {
color: $Form-select-menu-onActive-color;
background-color: $Form-select-menu-onActive-bg;
}
&.is-disabled {
pointer-events: none;
color: $text--muted-color;
@ -536,3 +551,23 @@
@include checkboxes-placeholder();
}
}
.#{$ns}AssociatedCheckboxes {
display: flex;
flex-direction: row;
&-left,
&-right {
flex-grow: 1;
width: 0;
overflow: auto;
}
&-left {
border-right: 1px solid $borderColor;
}
&-placeholder {
@include checkboxes-placeholder();
}
}

View File

@ -5,13 +5,164 @@
*/
import React from 'react';
import {CheckboxesProps, Checkboxes} from './Checkboxes';
import {Options, Option} from './Select';
import ListMenu from './ListMenu';
import {autobind} from '../utils/helper';
import ListRadios from './ListRadios';
import {themeable} from '../theme';
import uncontrollable from 'uncontrollable';
import ListCheckboxes from './ListCheckboxes';
import TableCheckboxes from './TableCheckboxes';
import TreeCheckboxes from './TreeCheckboxes';
import ChainedCheckboxes from './ChainedCheckboxes';
import Spinner from './Spinner';
import TreeRadios from './TreeRadios';
export interface AssociatedCheckboxesProps {}
export interface AssociatedCheckboxesProps extends CheckboxesProps {
leftOptions: Options;
leftMode?: 'tree' | 'list';
rightMode?: 'table' | 'list' | 'tree' | 'chained';
columns?: Array<any>;
cellRender?: (
column: {
name: string;
label: string;
[propName: string]: any;
},
option: Option,
colIndex: number,
rowIndex: number
) => JSX.Element;
}
export class AssociatedCheckboxes extends React.Component<
AssociatedCheckboxesProps
export interface AssociatedCheckboxesState {
leftValue?: Option;
}
export class AssociatedCheckboxes extends Checkboxes<
AssociatedCheckboxesProps,
AssociatedCheckboxesState
> {
state: AssociatedCheckboxesState = {};
@autobind
leftOption2Value(option: Option) {
return option.value;
}
@autobind
handleLeftSelect(value: Option) {
const {options, onDeferLoad} = this.props;
this.setState({leftValue: value});
const selectdOption = ListRadios.resolveSelected(
value,
options,
option => option.ref
);
if (selectdOption && onDeferLoad && selectdOption.defer) {
onDeferLoad(selectdOption);
}
}
render() {
return <div>todo</div>;
const {
classnames: cx,
className,
leftOptions,
options,
option2value,
rightMode,
onChange,
columns,
value,
leftMode,
cellRender
} = this.props;
const selectdOption = ListRadios.resolveSelected(
this.state.leftValue,
options,
option => option.ref
);
return (
<div className={cx('AssociatedCheckboxes', className)}>
<div className={cx('AssociatedCheckboxes-left')}>
{leftMode === 'tree' ? (
<TreeRadios
option2value={this.leftOption2Value}
options={leftOptions}
value={this.state.leftValue}
onChange={this.handleLeftSelect}
showRadio={false}
/>
) : (
<ListRadios
option2value={this.leftOption2Value}
options={leftOptions}
value={this.state.leftValue}
onChange={this.handleLeftSelect}
showRadio={false}
/>
)}
</div>
<div className={cx('AssociatedCheckboxes-right')}>
{this.state.leftValue ? (
selectdOption ? (
selectdOption.defer && selectdOption.loading ? (
<Spinner size="sm" show />
) : rightMode === 'table' ? (
<TableCheckboxes
columns={columns!}
value={value}
options={selectdOption.children || []}
onChange={onChange}
option2value={option2value}
cellRender={cellRender}
/>
) : rightMode === 'tree' ? (
<TreeCheckboxes
value={value}
options={selectdOption.children || []}
onChange={onChange}
option2value={option2value}
/>
) : rightMode === 'chained' ? (
<ChainedCheckboxes
value={value}
options={selectdOption.children || []}
onChange={onChange}
option2value={option2value}
/>
) : (
<ListCheckboxes
value={value}
options={selectdOption.children || []}
onChange={onChange}
option2value={option2value}
/>
)
) : (
<div className={cx('AssociatedCheckboxes-placeholder')}>
</div>
)
) : (
<div className={cx('AssociatedCheckboxes-placeholder')}>
</div>
)}
</div>
</div>
);
}
}
export default themeable(
uncontrollable(AssociatedCheckboxes, {
value: 'onChange'
})
);

View File

@ -18,7 +18,6 @@ export class ListCheckboxes extends Checkboxes {
} = this.props;
const valueArray = this.valueArray;
// todo 支持 option.defer 延时加载
if (Array.isArray(option.children)) {
return (
<div

View File

@ -0,0 +1,151 @@
import {Checkboxes} from './Checkboxes';
import {themeable, ThemeProps} from '../theme';
import React from 'react';
import uncontrollable from 'uncontrollable';
import Checkbox from './Checkbox';
import {Option, Options} from './Select';
import {findTree, autobind} from '../utils/helper';
import isEqual from 'lodash/isEqual';
export interface ListRadiosProps extends ThemeProps {
options: Options;
className?: string;
placeholder: string;
value?: any;
onChange?: (value: any) => void;
onDeferLoad?: (option: Option) => void;
option2value?: (option: Option) => any;
itemClassName?: string;
itemRender: (option: Option) => JSX.Element;
disabled?: boolean;
clearable?: boolean;
showRadio?: boolean;
}
export class ListRadios<
T extends ListRadiosProps = ListRadiosProps,
S = any
> extends React.Component<T, S> {
selected: Option | undefined | null;
static defaultProps = {
placeholder: '暂无选项',
itemRender: (option: Option) => <span>{option.label}</span>
};
static resolveSelected(
value: any,
options: Options,
option2value: (option: Option) => any = (option: Option) => option
) {
return findTree(options, option => isEqual(option2value(option), value));
}
@autobind
toggleOption(option: Option) {
const {onChange, clearable, value, options, option2value} = this.props;
let newValue: Option | null = option;
if (clearable) {
const prevSelected = ListRadios.resolveSelected(
value,
options,
option2value
);
if (prevSelected) {
newValue = null;
}
}
onChange?.(newValue && option2value ? option2value(newValue) : newValue);
}
renderOption(option: Option, index: number) {
const {
disabled,
classnames: cx,
itemClassName,
itemRender,
showRadio
} = this.props;
const selected = this.selected;
if (Array.isArray(option.children)) {
return (
<div key={index} className={cx('ListRadios-group', option.className)}>
<div className={cx('ListRadios-itemLabel')}>{itemRender(option)}</div>
<div className={cx('ListRadios-items', option.className)}>
{option.children.map((child, index) =>
this.renderOption(child, index)
)}
</div>
</div>
);
}
return (
<div
key={index}
className={cx(
'ListRadios-item',
itemClassName,
option.className,
disabled || option.disabled ? 'is-disabled' : '',
selected === option ? 'is-active' : ''
)}
onClick={() => this.toggleOption(option)}
>
<div className={cx('ListRadios-itemLabel')}>{itemRender(option)}</div>
{showRadio !== false ? (
<Checkbox
type="radio"
size="sm"
checked={selected === option}
disabled={disabled || option.disabled}
/>
) : null}
</div>
);
}
render() {
const {
value,
options,
className,
placeholder,
classnames: cx,
option2value
} = this.props;
this.selected = ListRadios.resolveSelected(value, options, option2value);
let body: Array<React.ReactNode> = [];
if (Array.isArray(options) && options.length) {
body = options.map((option, key) => this.renderOption(option, key));
}
return (
<div className={cx('ListRadios', className)}>
{body && body.length ? (
body
) : (
<div className={cx('ListRadios-placeholder')}>{placeholder}</div>
)}
</div>
);
}
}
const themedListRadios = themeable(
uncontrollable(ListRadios, {
value: 'onChange'
})
);
themedListRadios.resolveSelected = ListRadios.resolveSelected;
export default themedListRadios;

View File

@ -26,7 +26,9 @@ export class ResultBox extends React.Component<ResultBoxProps> {
clearable: false,
placeholder: '暂无结果',
inputPlaceholder: '手动输入内容',
itemRender: (option: any) => <span>{String(option.label)}</span>
itemRender: (option: any) => (
<span>{`${option.scopeLabel || ''}${option.label}`}</span>
)
};
state = {

View File

@ -24,7 +24,9 @@ export interface ResultListProps extends ThemeProps {
export class ResultList extends React.Component<ResultListProps> {
static defaultProps: Pick<ResultListProps, 'placeholder' | 'itemRender'> = {
placeholder: '请先选择数据',
itemRender: (option: Option) => <span>{option.label}</span>
itemRender: (option: any) => (
<span>{`${option.scopeLabel || ''}${option.label}`}</span>
)
};
id = guid();

View File

@ -18,7 +18,9 @@ export interface TableCheckboxesProps extends CheckboxesProps {
label: string;
[propName: string]: any;
},
option: Option
option: Option,
colIndex: number,
rowIndex: number
) => JSX.Element;
}
@ -31,7 +33,9 @@ export class TableCheckboxes extends Checkboxes<TableCheckboxesProps> {
label: string;
[propName: string]: any;
},
option: Option
option: Option,
colIndex: number,
rowIndex: number
) => <span>{resolveVariable(column.name, option)}</span>
};
@ -107,7 +111,9 @@ export class TableCheckboxes extends Checkboxes<TableCheckboxesProps> {
<Checkbox size="sm" checked={checked} />
</td>
{columns.map((column, colIndex) => (
<td key={colIndex}>{cellRender(column, option)}</td>
<td key={colIndex}>
{cellRender(column, option, colIndex, rowIndex)}
</td>
))}
</tr>
);

View File

@ -9,12 +9,24 @@ import ListCheckboxes from './ListCheckboxes';
import {Options, Option} from './Select';
import Transfer, {TransferProps} from './Transfer';
import {themeable} from '../theme';
import AssociatedCheckboxes from './AssociatedCheckboxes';
export interface TabsTransferProps
extends Omit<
TransferProps,
'selectMode' | 'columns' | 'selectRender' | 'statistics'
> {}
> {
cellRender?: (
column: {
name: string;
label: string;
[propName: string]: any;
},
option: Option,
colIndex: number,
rowIndex: number
) => JSX.Element;
}
export class TabsTransfer extends React.Component<TabsTransferProps> {
static defaultProps = {
@ -31,7 +43,8 @@ export class TabsTransfer extends React.Component<TabsTransferProps> {
classnames: cx,
value,
onChange,
option2value
option2value,
cellRender
} = this.props;
const options = searchResult || [];
const mode = searchResultMode;
@ -45,6 +58,7 @@ export class TabsTransfer extends React.Component<TabsTransferProps> {
value={value}
onChange={onChange}
option2value={option2value}
cellRender={cellRender}
/>
) : mode === 'tree' ? (
<TreeCheckboxes
@ -86,7 +100,8 @@ export class TabsTransfer extends React.Component<TabsTransferProps> {
onChange,
onSearch: searchable,
option2value,
onDeferLoad
onDeferLoad,
cellRender
} = this.props;
if (!Array.isArray(options) || !options.length) {
@ -132,6 +147,7 @@ export class TabsTransfer extends React.Component<TabsTransferProps> {
onChange={onChange}
option2value={option2value}
onDeferLoad={onDeferLoad}
cellRender={cellRender}
/>
) : option.selectMode === 'tree' ? (
<TreeCheckboxes
@ -152,6 +168,17 @@ export class TabsTransfer extends React.Component<TabsTransferProps> {
onDeferLoad={onDeferLoad}
defaultSelectedIndex={option.defaultSelectedIndex}
/>
) : option.selectMode === 'associated' ? (
<AssociatedCheckboxes
className={cx('Transfer-checkboxes')}
options={option.children || []}
value={value}
onChange={onChange}
option2value={option2value}
onDeferLoad={onDeferLoad}
leftMode={option.leftMode}
leftOptions={option.leftOptions}
/>
) : (
<ListCheckboxes
className={cx('Transfer-checkboxes')}

View File

@ -12,6 +12,7 @@ import InputBox from './InputBox';
import {Icon} from './icons';
import debounce from 'lodash/debounce';
import ChainedCheckboxes from './ChainedCheckboxes';
import AssociatedCheckboxes from './AssociatedCheckboxes';
export interface TransferProps extends ThemeProps, CheckboxesProps {
inline?: boolean;
@ -19,12 +20,25 @@ export interface TransferProps extends ThemeProps, CheckboxesProps {
showArrow?: boolean;
selectTitle: string;
selectMode?: 'table' | 'list' | 'tree' | 'chained';
selectMode?: 'table' | 'list' | 'tree' | 'chained' | 'associated';
columns?: Array<{
name: string;
label: string;
[propName: string]: any;
}>;
cellRender?: (
column: {
name: string;
label: string;
[propName: string]: any;
},
option: Option,
colIndex: number,
rowIndex: number
) => JSX.Element;
leftOptions?: Array<Option>;
leftMode?: 'tree' | 'list';
rightMode?: 'table' | 'list' | 'tree' | 'chained';
// search 相关
searchResultMode?: 'table' | 'list' | 'tree' | 'chained';
@ -246,7 +260,8 @@ export class Transfer extends React.Component<TransferProps, TransferState> {
classnames: cx,
value,
onChange,
option2value
option2value,
cellRender
} = this.props;
const options = this.state.searchResult || [];
const mode = searchResultMode || selectMode;
@ -260,6 +275,7 @@ export class Transfer extends React.Component<TransferProps, TransferState> {
value={value}
onChange={onChange}
option2value={option2value}
cellRender={cellRender}
/>
) : mode === 'tree' ? (
<TreeCheckboxes
@ -300,7 +316,11 @@ export class Transfer extends React.Component<TransferProps, TransferState> {
onChange,
option2value,
classnames: cx,
onDeferLoad
onDeferLoad,
leftOptions,
leftMode,
rightMode,
cellRender
} = this.props;
return selectMode === 'table' ? (
@ -312,6 +332,7 @@ export class Transfer extends React.Component<TransferProps, TransferState> {
onChange={onChange}
option2value={option2value}
onDeferLoad={onDeferLoad}
cellRender={cellRender}
/>
) : selectMode === 'tree' ? (
<TreeCheckboxes
@ -331,6 +352,19 @@ export class Transfer extends React.Component<TransferProps, TransferState> {
option2value={option2value}
onDeferLoad={onDeferLoad}
/>
) : selectMode === 'associated' ? (
<AssociatedCheckboxes
className={cx('Transfer-checkboxes')}
options={options || []}
value={value}
onChange={onChange}
option2value={option2value}
onDeferLoad={onDeferLoad}
columns={columns}
leftOptions={leftOptions || []}
leftMode={leftMode}
rightMode={rightMode}
/>
) : (
<ListCheckboxes
className={cx('Transfer-checkboxes')}

View File

@ -0,0 +1,197 @@
import {themeable} from '../theme';
import React from 'react';
import uncontrollable from 'uncontrollable';
import Checkbox from './Checkbox';
import {Option} from './Select';
import {autobind, eachTree, everyTree} from '../utils/helper';
import Spinner from './Spinner';
import {ListRadiosProps, ListRadios} from './ListRadios';
export interface TreeRadiosProps extends ListRadiosProps {
expand?: 'all' | 'first' | 'root' | 'none';
}
export interface TreeRadiosState {
expanded: Array<string>;
}
export class TreeRadios extends ListRadios<TreeRadiosProps, TreeRadiosState> {
state: TreeRadiosState = {
expanded: []
};
static defaultProps = {
...ListRadios.defaultProps,
expand: 'first' as 'first'
};
componentDidMount() {
this.syncExpanded();
}
componentDidUpdate(prevProps: TreeRadiosProps) {
const props = this.props;
if (
!this.state.expanded.length &&
(props.expand !== prevProps.expand || props.options !== prevProps.options)
) {
this.syncExpanded();
}
}
syncExpanded() {
const options = this.props.options;
const mode = this.props.expand;
const expanded: Array<string> = [];
if (!Array.isArray(options)) {
return;
}
if (mode === 'first' || mode === 'root') {
options.every((option, index) => {
if (Array.isArray(option.children)) {
expanded.push(`${index}`);
return mode === 'root';
}
return true;
});
} else if (mode === 'all') {
everyTree(options, (option, index, level, paths, indexes) => {
if (Array.isArray(option.children)) {
expanded.push(`${indexes.concat(index).join('-')}`);
}
return true;
});
}
this.setState({expanded});
}
toggleCollapsed(option: Option, index: string) {
const onDeferLoad = this.props.onDeferLoad;
const expanded = this.state.expanded.concat();
const idx = expanded.indexOf(index);
if (~idx) {
expanded.splice(idx, 1);
} else {
expanded.push(index);
}
this.setState(
{
expanded: expanded
},
option.defer && onDeferLoad ? () => onDeferLoad(option) : undefined
);
}
renderItem(option: Option, index: number, indexes: Array<number> = []) {
const {
disabled,
classnames: cx,
itemClassName,
itemRender,
showRadio
} = this.props;
const id = indexes.join('-');
let hasChildren = Array.isArray(option.children) && option.children.length;
const checked = option === this.selected;
const expaned = !!~this.state.expanded.indexOf(id);
return (
<div
key={index}
className={cx(
'TreeRadios-item',
disabled || option.disabled || (option.defer && option.loading)
? 'is-disabled'
: '',
expaned ? 'is-expanded' : '',
checked ? 'is-active' : ''
)}
>
<div
className={cx(
'TreeRadios-itemInner',
itemClassName,
option.className,
checked ? 'is-active' : ''
)}
onClick={() => this.toggleOption(option)}
>
{hasChildren || option.defer ? (
<a
onClick={(e: React.MouseEvent<any>) => {
e.stopPropagation();
this.toggleCollapsed(option, id);
}}
className={cx('Table-expandBtn', expaned ? 'is-active' : '')}
>
<i />
</a>
) : null}
<div className={cx('TreeRadios-itemLabel')}>{itemRender(option)}</div>
{option.defer && option.loading ? <Spinner show size="sm" /> : null}
{(!option.defer || option.loaded) &&
option.value !== undefined &&
showRadio !== false ? (
<Checkbox
type="radio"
size="sm"
checked={checked}
disabled={disabled || option.disabled}
/>
) : null}
</div>
{hasChildren ? (
<div className={cx('TreeRadios-sublist')}>
{option.children!.map((option, key) =>
this.renderItem(option, key, indexes.concat(key))
)}
</div>
) : null}
</div>
);
}
render() {
const {
value,
options,
className,
placeholder,
classnames: cx,
option2value
} = this.props;
this.selected = ListRadios.resolveSelected(value, options, option2value);
let body: Array<React.ReactNode> = [];
if (Array.isArray(options) && options.length) {
body = options.map((option, key) => this.renderItem(option, key, [key]));
}
return (
<div className={cx('TreeRadios', className)}>
{body && body.length ? (
body
) : (
<div className={cx('TreeRadios-placeholder')}>{placeholder}</div>
)}
</div>
);
}
}
export default themeable(
uncontrollable(TreeRadios, {
value: 'onChange'
})
);

View File

@ -47,6 +47,8 @@ import TreeCheckboxes from './TreeCheckboxes';
import ChainedCheckboxes from './ChainedCheckboxes';
import ResultBox from './ResultBox';
import InputBox from './InputBox';
import ListRadios from './ListRadios';
import TreeRadios from './TreeRadios';
export {
NotFound,
@ -96,5 +98,7 @@ export {
TreeCheckboxes,
ChainedCheckboxes,
ResultBox,
InputBox
InputBox,
ListRadios,
TreeRadios
};

View File

@ -27,7 +27,8 @@ export class TabsTransferRenderer extends TransferRenderer<TabsTransferProps> {
loading,
searchable,
searchResultMode,
showArrow
showArrow,
deferLoad
} = this.props;
return (
@ -41,6 +42,7 @@ export class TabsTransferRenderer extends TransferRenderer<TabsTransferProps> {
searchResultMode={searchResultMode}
onSearch={searchable ? this.handleSearch : undefined}
showArrow={showArrow}
onDeferLoad={deferLoad}
/>
<Spinner overlay key="info" show={loading} />

View File

@ -13,11 +13,16 @@ import {Api} from '../../types';
import Spinner from '../../components/Spinner';
import find from 'lodash/find';
import {optionValueCompare} from '../../components/Select';
import {resolveVariable} from '../../utils/tpl-builtin';
export interface TransferProps extends OptionsControlProps {
showArrow?: boolean;
sortable?: boolean;
selectMode?: 'table' | 'list' | 'tree' | 'chained';
selectMode?: 'table' | 'list' | 'tree' | 'chained' | 'associated';
leftOptions?: Array<Option>;
leftMode?: 'tree' | 'list' | 'chained';
rightMode?: 'table' | 'list' | 'tree' | 'chained';
searchResultMode?: 'table' | 'list' | 'tree' | 'chained';
columns?: Array<any>;
searchable?: boolean;
@ -137,6 +142,31 @@ export class TransferRenderer<
}
}
@autobind
renderCell(
column: {
name: string;
label: string;
[propName: string]: any;
},
option: Option,
colIndex: number,
rowIndex: number
) {
const {render, data} = this.props;
return render(
`cell/${colIndex}/${rowIndex}`,
{
type: 'text',
...column
},
{
value: resolveVariable(column.name, option),
data: createObject(data, option)
}
);
}
render() {
const {
className,
@ -150,7 +180,10 @@ export class TransferRenderer<
loading,
searchable,
searchResultMode,
deferLoad
deferLoad,
leftOptions,
leftMode,
rightMode
} = this.props;
return (
@ -167,6 +200,10 @@ export class TransferRenderer<
columns={columns}
onSearch={searchable ? this.handleSearch : undefined}
onDeferLoad={deferLoad}
leftOptions={leftOptions}
leftMode={leftMode}
rightMode={rightMode}
cellRender={this.renderCell}
/>
<Spinner overlay key="info" show={loading} />