ui: new autocomplete components (#190)

This commit is contained in:
无限UCW authored and GitHub committed 2021-08-24 00:00:28 +08:00
1 parent 9eece289e0
commit 89d100c548
16 files changed
+839 -419

No files matched your search

+4
View File
@@ -55,6 +55,10 @@ module.exports = {
// FIXME A bug with eslint-parser
'template-curly-spacing': 'off',
// Note: must disable the base rule as it can report incorrect errors
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': ['error'],
'comma-dangle': [
'error',
'always-multiline',
@@ -1,44 +0,0 @@
import { assign } from 'lodash';
import tpl from 'vj/utils/tpl';
import request from 'vj/utils/request';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import AutoComplete from '.';
function getText(domain) {
return domain._id;
}
function getItems(val) {
return request.get('/domain/search', { q: val });
}
function renderItem(domain) {
return tpl`
<div class="media">
<div class="media__left medium">
<img class="small domain-profile-avatar" src="${domain.avatarUrl}" width="30" height="30">
</div>
<div class="media__body medium">
<div class="domain-select__name">${domain.name}</div>
<div class="domain-select__id">ID = ${domain._id}</div>
</div>
</div>
`;
}
export default class DomainSelectAutoComplete extends AutoComplete {
static DOMAttachKey = 'vjDomainSelectAutoCompleteInstance';
constructor($dom, options) {
super($dom, {
classes: 'domain-select',
items: getItems,
render: renderItem,
text: getText,
...options,
});
}
}
assign(DomainSelectAutoComplete, DOMAttachedObject);
window.Hydro.components.DomainSelectAutoComplete = DomainSelectAutoComplete;
@@ -0,0 +1,35 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { assign } from 'lodash';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import AutoComplete from '.';
import DomainSelectAutoCompleteFC from './components/DomainSelectAutoComplete';
export default class DomainSelectAutoComplete extends AutoComplete {
static DOMAttachKey = 'ucwDomainSelectAutoCompleteInstance';
constructor($dom, options) {
super($dom, {
classes: 'domain-select',
...options,
});
}
attach() {
const value = this.$dom.val();
ReactDOM.render(
<DomainSelectAutoCompleteFC
ref={(ref) => { this.ref = ref; }}
height="34px"
defaultItems={value}
onChange={this.onChange}
multi={this.options.multi}
freeSolo={this.options.multi}
/>,
this.container,
);
}
}
assign(DomainSelectAutoComplete, DOMAttachedObject);
window.Hydro.components.DomainSelectAutoComplete = DomainSelectAutoComplete;
@@ -1,41 +0,0 @@
import { assign } from 'lodash';
import tpl from 'vj/utils/tpl';
import request from 'vj/utils/request';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import AutoComplete from './index';
function getText(pdoc) {
return pdoc.docId;
}
function getItems(prefix) {
return request.get(`/d/${UiContext.domainId}/problem/list`, { prefix });
}
function renderItem(pdoc) {
return tpl`
<div class="media">
<div class="media__body medium">
<div class="problem-select__name">${pdoc.pid ? `${pdoc.pid} ` : ''}${pdoc.title}</div>
<div class="problem-select__id">ID = ${pdoc.docId}</div>
</div>
</div>
`;
}
export default class ProblemSelectAutoComplete extends AutoComplete {
static DOMAttachKey = 'vjProblemSelectAutoCompleteInstance';
constructor($dom, options) {
super($dom, {
classes: 'problem-select',
items: getItems,
render: renderItem,
text: getText,
...options,
});
}
}
assign(ProblemSelectAutoComplete, DOMAttachedObject);
window.Hydro.components.ProblemSelectAutoComplete = ProblemSelectAutoComplete;
@@ -0,0 +1,35 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { assign } from 'lodash';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import AutoComplete from '.';
import ProblemSelectAutoCompleteFC from './components/ProblemSelectAutoComplete';
export default class ProblemSelectAutoComplete extends AutoComplete {
static DOMAttachKey = 'ucwProblemSelectAutoCompleteInstance';
constructor($dom, options) {
super($dom, {
classes: 'problem-select',
...options,
});
}
attach() {
const value = this.$dom.val();
ReactDOM.render(
<ProblemSelectAutoCompleteFC
ref={(ref) => { this.ref = ref; }}
height="34px"
defaultItems={value}
onChange={this.onChange}
multi={this.options.multi}
freeSolo={this.options.multi}
/>,
this.container,
);
}
}
assign(ProblemSelectAutoComplete, DOMAttachedObject);
window.Hydro.components.ProblemSelectAutoComplete = ProblemSelectAutoComplete;
@@ -1,44 +0,0 @@
import _ from 'lodash';
import tpl from 'vj/utils/tpl';
import request from 'vj/utils/request';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import AutoComplete from './index';
function getText(user) {
return user.uname;
}
function getItems(val) {
return request.get('/user/search', { q: val });
}
function renderItem(user) {
return tpl`
<div class="media">
<div class="media__left medium">
<img class="small user-profile-avatar" src="${user.avatarUrl}" width="30" height="30">
</div>
<div class="media__body medium">
<div class="user-select__uname">${user.uname}</div>
<div class="user-select__uid">UID = ${user._id}</div>
</div>
</div>
`;
}
export default class UserSelectAutoComplete extends AutoComplete {
static DOMAttachKey = 'vjUserSelectAutoCompleteInstance';
constructor($dom, options) {
super($dom, {
classes: 'user-select',
items: getItems,
render: renderItem,
text: getText,
...options,
});
}
}
_.assign(UserSelectAutoComplete, DOMAttachedObject);
window.Hydro.components.UserSelectAutoComplete = UserSelectAutoComplete;
@@ -0,0 +1,35 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { assign } from 'lodash';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import AutoComplete from '.';
import UserSelectAutoCompleteFC from './components/UserSelectAutoComplete';
export default class UserSelectAutoComplete extends AutoComplete {
static DOMAttachKey = 'ucwUserSelectAutoCompleteInstance';
constructor($dom, options) {
super($dom, {
classes: 'user-select',
...options,
});
}
attach() {
const value = this.$dom.val();
ReactDOM.render(
<UserSelectAutoCompleteFC
ref={(ref) => { this.ref = ref; }}
height="34px"
defaultItems={value}
onChange={this.onChange}
multi={this.options.multi}
freeSolo={this.options.multi}
/>,
this.container,
);
}
}
assign(UserSelectAutoComplete, DOMAttachedObject);
window.Hydro.components.UserSelectAutoComplete = UserSelectAutoComplete;
@@ -1,11 +1,112 @@
.drop-element.autocomplete
max-width: rem(250px)
.autocomplete-dummy
display: none !important
.empty-row
color: $autocomplete-empty-row-color
font-size: $(font-size-small)
padding: rem(10px)
.autocomplete-wrapper
width: 300px
border: 1px solid #d9d9d9
background-color: #fff
padding: 1px
display: flex
flex-wrap: wrap
overflow-y: auto
.menu
max-height: rem(250px)
overflow: auto
&:hover
border-color: #40a9ff
&.focused
border-color: #40a9ff
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2)
input
background-color: #fff
color: rgba(0,0,0,.85)
height: 30px
box-sizing: border-box
padding: 4px 6px
width: 0
min-width: 30px
flex-grow: 1
border: 0
margin: 0
outline: 0
.autocomplete-tag
display: flex
align-items: center
height: 24px
margin: 2px
line-height: 22px
background-color: #fafafa
border: 1px solid #e8e8e8
border-radius: 2px
box-sizing: content-box
padding: 0 4px 0 10px
outline: 0
overflow: hidden
&:focus
border-color: #40a9ff
background-color: #e6f7ff
&>div
overflow: hidden
white-space: nowrap
text-overflow: ellipsis
&>svg
font-size: 12px
cursor: pointer
padding: 4px
.autocomplete-list
min-width: 300px
margin: 2px 0 0
padding: 0
position: absolute
list-style: none
background-color: #fff
overflow: auto
max-height: 500px
border-radius: 4px
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15)
z-index: 1
li
padding: 5px 12px
display: flex
&>div
flex-grow: 1
&>svg
color: transparent
&[aria-selected='true']
background-color: #fafafa
font-weight: 600
svg
color: #1890ff
&[data-focus='true']
background-color: #e6f7ff
cursor: pointer
svg
color: currentColor
svg.autocomplete-icon
-webkit-user-select: none
-moz-user-select: none
-ms-user-select: none
user-select: none
width: 1em
height: 1em
display: inline-block
fill: currentColor
-webkit-flex-shrink: 0
-ms-flex-negative: 0
flex-shrink: 0
-webkit-transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms
transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms
font-size: 1.25rem
@@ -0,0 +1,270 @@
/* eslint-disable jsx-a11y/role-supports-aria-props */
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React, {
forwardRef, useState, useRef, useImperativeHandle, createRef, useEffect,
} from 'react';
import PropTypes from 'prop-types';
import { debounce } from 'lodash';
import { Icon } from '@blueprintjs/core';
// eslint-disable-next-line prefer-arrow-callback
const AutoComplete = forwardRef(function AutoComplete(props, ref) {
const width = props.width ?? '100%';
// if you need fix height, set to at least "30px"
// for Hydro, no less then "34px" can be better
const height = props.height ?? 'auto';
const listStyle = props.listStyle ?? {};
const itemsFn = props.itemsFn ?? (async () => []);
const renderItem = props.renderItem ?? ((item) => item);
const itemText = props.itemText ?? ((item) => item);
const itemKey = props.itemKey ?? itemText;
const onChange = props.onChange ?? (() => { });
const multi = props.multi ?? false;
const rawDefaultItems = props.defaultItems ?? [];
const defaultItems = typeof rawDefaultItems === 'string'
? props.defaultItems.split(',').map((i) => i.trim()).filter((i) => i.length > 0) : rawDefaultItems;
const allowEmptyQuery = props.allowEmptyQuery ?? false;
const freeSolo = props.freeSolo ?? false;
const freeSoloConverter = freeSolo ? props.freeSoloConverter ?? ((i) => i) : ((i) => i);
const [focused, setFocused] = useState(false); // is focused
const [selected, setSelected] = useState(defaultItems); // selected items
const [selectedKeys, setSelectedKeys] = useState(defaultItems.map((i) => itemKey(i))); // keys of selected items
const [itemList, setItemList] = useState([]); // items list
const [currentItem, setCurrentItem] = useState(null); // index of current item (in item list)
const inputRef = createRef();
const listRef = createRef();
const cache = useRef({});
const queryList = async (query) => {
if (!query && !allowEmptyQuery) {
setItemList([]);
setCurrentItem(null);
return;
}
let items;
if (cache.current[query]) {
items = cache.current[query];
} else {
items = await itemsFn(query);
cache.current[query] = items;
}
setItemList(items);
setCurrentItem(null);
};
const calculateValue = () => {
const query = inputRef.current?.value;
if (!query) return multi ? selectedKeys.join(',') : '';
return (multi && selectedKeys.length > 0) ? `${selectedKeys.join(',')},${query}` : query;
};
const dispatchChange = () => {
const value = calculateValue();
onChange(value);
};
useEffect(() => {
dispatchChange();
}, [selectedKeys, multi]);
const handleInputChange = debounce(async (e) => {
const { target } = e;
const { value } = target;
queryList(value);
}, 300);
const toggleItem = (item) => {
if (multi) {
const key = itemKey(item);
const idx = selectedKeys.indexOf(key);
if (idx !== -1) {
setSelected((s) => {
const newSelected = [...s];
newSelected.splice(idx, 1);
return newSelected;
});
setSelectedKeys((s) => {
const newSelectedKeys = [...s];
newSelectedKeys.splice(idx, 1);
return newSelectedKeys;
});
} else {
setSelected((s) => [...s, item]);
setSelectedKeys((s) => [...s, key]);
}
inputRef.current.value = '';
inputRef.current.focus();
} else {
inputRef.current.value = itemKey(item);
}
setItemList([]);
setCurrentItem(null);
};
const handleInputKeyDown = (e) => {
const { key, target } = e;
if (key === 'Escape') {
setItemList([]);
setCurrentItem(null);
return;
}
if (key === 'Enter' || key === ',') {
e.preventDefault();
if (currentItem !== null) {
toggleItem(itemList[currentItem]);
return;
}
if (freeSolo && target.value !== '') {
toggleItem(freeSoloConverter(target.value));
}
return;
}
if (key === 'Backspace') {
if (target.value.length > 0) return;
if (selected.length > 0) {
setSelected((s) => s.slice(0, -1));
setSelectedKeys((s) => s.slice(0, -1));
}
return;
}
if (key === 'ArrowUp') {
e.preventDefault();
if (itemList.length === 0) return;
const idx = (currentItem ?? 0) - 1;
const newIdx = idx < 0 ? itemList.length - 1 : idx;
setCurrentItem(newIdx);
listRef.current.children[newIdx].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
return;
}
if (key === 'ArrowDown') {
e.preventDefault();
if (itemList.length === 0) return;
const idx = (currentItem ?? itemList.length - 1) + 1;
const newIdx = idx >= itemList.length ? 0 : idx;
setCurrentItem(newIdx);
listRef.current.children[newIdx].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
// eslint-disable-next-line no-useless-return
return;
}
// TODO: handle other keys
};
useImperativeHandle(ref, () => ({
getSelectedItems: () => selected,
getSelectedItemsAsString: () => selectedKeys.join(','),
setSelectedItems: (items) => {
setSelected(items);
setSelectedKeys(items.map((i) => itemKey(i)));
},
getQuery: () => inputRef.current?.value,
setQuery: (query) => {
if (inputRef.current) inputRef.current.value = query;
},
triggerQuery: () => queryList(inputRef.current?.value),
closeList: () => {
setItemList([]);
setCurrentItem(null);
},
getValue: () => (multi ? selectedKeys.join(',') : (inputRef.current.value ?? '')),
getValueArray: () => (multi ? selected : [inputRef.current?.value].filter((i) => !!i)),
getValueWithQuery: () => calculateValue(),
getValueArrayWithQuery: () => {
const query = inputRef.current?.value;
if (!query) return multi ? selected : [''];
return multi ? [...selected, query] : [query];
},
clear: () => {
setSelected([]);
setSelectedKeys([]);
if (inputRef.current) inputRef.current.value = '';
},
focus: () => {
setFocused(true);
inputRef.current?.focus();
},
}));
return (
<div style={{ display: 'inline-block', width: '100%' }}>
<div
className={focused ? 'autocomplete-wrapper focused' : 'autocomplete-wrapper'}
style={{ width, height }}
>
{multi && selected.map((item, idx) => (
<div className="autocomplete-tag" key={selectedKeys[idx]}>
<div>{itemText(item)}</div>
<Icon icon="cross" onClick={() => toggleItem(item)} />
</div>
))}
<input
ref={inputRef}
autoComplete="off"
onChange={(e) => {
dispatchChange();
handleInputChange(e);
}}
onFocus={() => {
setFocused(true);
if (allowEmptyQuery) {
handleInputChange();
}
}}
onBlur={() => setFocused(false)}
onKeyDown={handleInputKeyDown}
/>
</div>
{focused && itemList.length > 0 && (
<ul ref={listRef} className="autocomplete-list" style={listStyle} onMouseDown={(e) => e.preventDefault()}>
{itemList.map((item, idx) => (
<li
key={itemKey(item)}
onClick={() => toggleItem(item)}
onMouseMove={() => setCurrentItem(idx)}
aria-selected={selectedKeys.includes(itemKey(item))}
data-focus={idx === currentItem}
>
<div>{renderItem(item)}</div>
<Icon icon="tick" />
</li>
))}
</ul>
)}
</div>
);
});
AutoComplete.propTypes = {
width: PropTypes.string,
height: PropTypes.string,
listStyle: PropTypes.object,
itemsFn: PropTypes.func.isRequired,
itemKey: PropTypes.func,
renderItem: PropTypes.func,
itemText: PropTypes.func,
onChange: PropTypes.func.isRequired,
multi: PropTypes.bool,
defaultItems: PropTypes.oneOfType([(PropTypes.arrayOf(PropTypes.any)), PropTypes.string]),
allowEmptyQuery: PropTypes.bool,
freeSolo: PropTypes.bool,
freeSoloConverter: PropTypes.func,
};
AutoComplete.defaultProps = {
width: '100%',
height: 'auto',
listStyle: {},
renderItem: (item) => item,
itemText: (item) => item,
multi: false,
defaultItems: [],
allowEmptyQuery: false,
freeSolo: false,
freeSoloConverter: (input) => input,
};
AutoComplete.displayName = 'AutoComplete';
export default AutoComplete;
@@ -0,0 +1,65 @@
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import request from 'vj/utils/request';
import AutoComplete from './AutoComplete';
// eslint-disable-next-line prefer-arrow-callback
const DomainSelectAutoComplete = forwardRef(function DomainSelectAutoComplete(props, ref) {
const itemsFn = (query) => request.get('/domain/search', { q: query });
/* const itemText = (domain) => {
return domain.name;
}; */
const itemKey = (domain) => domain._id || domain;
const renderItem = (domain) => (
<div className="media">
<div className="media__left medium">
<img className="small domain-profile-avatar" src={domain.avatarUrl} alt="" width="30" height="30" />
</div>
<div className="media__body medium">
<div className="domain-select__name">{domain.name}</div>
<div className="domain-select__id">ID = {domain._id}</div>
</div>
</div>
);
return (
<AutoComplete
ref={ref}
itemsFn={itemsFn}
itemText={itemKey}
itemKey={itemKey}
renderItem={renderItem}
{...props}
/>
);
});
DomainSelectAutoComplete.propTypes = {
width: PropTypes.string,
height: PropTypes.string,
listStyle: PropTypes.object,
onChange: PropTypes.func.isRequired,
multi: PropTypes.bool,
defaultItems: PropTypes.oneOfType([(PropTypes.arrayOf(PropTypes.any)), PropTypes.string]),
allowEmptyQuery: PropTypes.bool,
freeSolo: PropTypes.bool,
freeSoloConverter: PropTypes.func,
};
DomainSelectAutoComplete.defaultProps = {
width: '100%',
height: 'auto',
listStyle: {},
multi: false,
defaultItems: [],
allowEmptyQuery: false,
freeSolo: false,
freeSoloConverter: (input) => input,
};
DomainSelectAutoComplete.displayName = 'DomainSelectAutoComplete';
export default DomainSelectAutoComplete;
@@ -0,0 +1,62 @@
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import request from 'vj/utils/request';
import AutoComplete from './AutoComplete';
// eslint-disable-next-line prefer-arrow-callback
const ProblemSelectAutoComplete = forwardRef(function ProblemSelectAutoComplete(props, ref) {
const itemsFn = (query) => request.get(`/d/${UiContext.domainId}/problem/list`, { prefix: query });
/* const itemText = (pdoc) => {
return (pdoc.pid ? `${pdoc.pid} ` : '') + pdoc.title;
}; */
const itemKey = (pdoc) => pdoc.docId || pdoc;
const renderItem = (pdoc) => (
<div className="media">
<div className="media__body medium">
<div className="problem-select__name">{pdoc.pid ? `${pdoc.pid} ` : ''}{pdoc.title}</div>
<div className="problem-select__id">ID = {pdoc.docId}</div>
</div>
</div>
);
return (
<AutoComplete
ref={ref}
itemsFn={itemsFn}
itemText={itemKey}
itemKey={itemKey}
renderItem={renderItem}
{...props}
/>
);
});
ProblemSelectAutoComplete.propTypes = {
width: PropTypes.string,
height: PropTypes.string,
listStyle: PropTypes.object,
onChange: PropTypes.func.isRequired,
multi: PropTypes.bool,
defaultItems: PropTypes.oneOfType([(PropTypes.arrayOf(PropTypes.any)), PropTypes.string]),
allowEmptyQuery: PropTypes.bool,
freeSolo: PropTypes.bool,
freeSoloConverter: PropTypes.func,
};
ProblemSelectAutoComplete.defaultProps = {
width: '100%',
height: 'auto',
listStyle: {},
multi: false,
defaultItems: [],
allowEmptyQuery: false,
freeSolo: false,
freeSoloConverter: (input) => input,
};
ProblemSelectAutoComplete.displayName = 'ProblemSelectAutoComplete';
export default ProblemSelectAutoComplete;
@@ -0,0 +1,60 @@
import React, { forwardRef } from 'react';
import PropTypes from 'prop-types';
import request from 'vj/utils/request';
import AutoComplete from './AutoComplete';
// eslint-disable-next-line prefer-arrow-callback
const UserSelectAutoComplete = forwardRef(function UserSelectAutoComplete(props, ref) {
const itemsFn = (query) => request.get('/user/search', { q: query });
const itemText = (user) => user.uname || user;
const renderItem = (user) => (
<div className="media">
<div className="media__left medium">
<img className="small user-profile-avatar" alt="" src={user.avatarUrl} width="30" height="30" />
</div>
<div className="media__body medium">
<div className="user-select__uname">{user.uname}</div>
<div className="user-select__uid">UID = {user._id}</div>
</div>
</div>
);
return (
<AutoComplete
ref={ref}
itemsFn={itemsFn}
itemText={itemText}
renderItem={renderItem}
{...props}
/>
);
});
UserSelectAutoComplete.propTypes = {
width: PropTypes.string,
height: PropTypes.string,
listStyle: PropTypes.object,
onChange: PropTypes.func.isRequired,
multi: PropTypes.bool,
defaultItems: PropTypes.oneOfType([(PropTypes.arrayOf(PropTypes.any)), PropTypes.string]),
allowEmptyQuery: PropTypes.bool,
freeSolo: PropTypes.bool,
freeSoloConverter: PropTypes.func,
};
UserSelectAutoComplete.defaultProps = {
width: '100%',
height: 'auto',
listStyle: {},
multi: false,
defaultItems: [],
allowEmptyQuery: false,
freeSolo: false,
freeSoloConverter: (input) => input,
};
UserSelectAutoComplete.displayName = 'UserSelectAutoComplete';
export default UserSelectAutoComplete;
@@ -1,198 +0,0 @@
import Drop from 'tether-drop';
import { assign } from 'lodash';
import 'jquery-scroll-lock';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import i18n from 'vj/utils/i18n';
import tpl from 'vj/utils/tpl';
import zIndexManager from 'vj/utils/zIndexManager';
export default class AutoComplete extends DOMAttachedObject {
static DOMAttachKey = 'vjAutoCompleteInstance';
constructor($dom, options = {}) {
super($dom);
this.options = {
items: async () => [],
render: () => '',
text: () => null,
minChar: 1,
cache: true,
clearDefaultValue: true,
position: 'bottom left',
classes: '',
multi: false,
...options,
};
this.clear(this.options.clearDefaultValue);
this.menuShown = false;
this.cache = {};
this.currentItems = [];
this.$menu = $(`<ol class="menu ${this.options.classes}"></ol>`);
this.$menu.scrollLock({ strict: false });
this.$menu.on('mousedown', this.onMenuClick.bind(this));
this.$menu.on('mousedown', '.menu__item', this.onItemClick.bind(this));
this.dropInstance = new Drop({
classes: 'autocomplete dropdown',
target: this.$dom[0],
content: this.$menu[0],
position: this.options.position,
constrainToWindow: false,
constrainToScrollParent: false,
openOn: false,
});
this.dropInstance.on('open', this.onDropOpen.bind(this));
this.attach();
}
clear(clearValue = true) {
if (clearValue) this.$dom.val('');
this._value = null;
this.lastText = null;
}
attach() {
this.$dom.on(`click.${this.eventNS}`, this.onClick.bind(this));
this.$dom.on(`focus.${this.eventNS}`, this.onFocus.bind(this));
this.$dom.on(`blur.${this.eventNS}`, this.onBlur.bind(this));
this.$dom.on(`keydown.${this.eventNS}`, this.onKeyDown.bind(this));
this.$dom.on(`keyup.${this.eventNS}`, this.onKeyUp.bind(this));
}
onClick() {
this.updateOpenState();
}
onFocus() {
this.isFocus = true;
this.updateOpenState();
}
onBlur() {
this.isFocus = false;
this.updateOpenState();
}
onKeyDown() {
// TODO: Implement keyboard navigation
}
onKeyUp(ev) {
if (ev.which === 27 /* ESC */) {
this.close();
return;
}
if (this.$dom.val() === this.lastText) return;
this.lastText = this.$dom.val();
this.updateOpenState();
if (this.isOpen) this.renderList();
}
onMenuClick(ev) {
ev.preventDefault(); // prevent from losing focus
}
onItemClick(ev) {
const idx = $(ev.currentTarget).attr('data-idx');
if (idx === undefined) return;
const item = this.currentItems[idx];
const text = this.options.text(item);
this._value = item;
this.$dom.trigger('vjAutoCompleteSelect', item);
if (text != null) {
if (!this.options.multi) this.$dom.val(text);
else {
const current = this.$dom.val().replace(/,/g, ',');
if (current.indexOf(',') !== -1) {
const items = current.split(',');
items[items.length - 1] = text;
this.$dom.val(items.join(', '));
} else this.$dom.val(`${text}, `);
}
}
this.close();
}
onDropOpen() {
$(this.dropInstance.drop).css('z-index', zIndexManager.getNext());
}
async getItems(val) {
if (this.cache[val] !== undefined) return this.cache[val];
const data = await this.options.items(val);
if (this.options.cache) this.cache[val] = data;
return data;
}
getHtml(items) {
if (items.length === 0) {
return tpl`<div class="empty-row">${i18n('Oops, there are no results.')}</div>`;
}
return items.map((item, idx) => `
<li class="menu__item" data-idx="${idx}"><a href="javascript:;" class="menu__link">
${this.options.render(item)}
</a></li>
`).join('\n');
}
async renderList() {
let val = this.$dom.val();
if (this.options.multi) {
const elems = val.split(',').map((i) => i.trim());
val = elems[elems.length - 1];
}
const items = await this.getItems(val);
const html = this.getHtml(items);
this.currentItems = items;
this.$menu.html(html);
}
open() {
if (this.isOpen) return;
this.dropInstance.open();
this.isOpen = true;
}
close() {
if (!this.isOpen) return;
this.dropInstance.close();
this.isOpen = false;
}
updateOpenState() {
let content = this.$dom.val();
if (this.options.multi) {
content = content.split(',');
content = content[content.length - 1].trim();
}
if (this.isFocus && content.length >= this.options.minChar) {
if (!this.isOpen) {
this.open();
this.renderList();
}
} else if (this.isOpen) this.close();
}
detach() {
if (this.detached) return;
super.detach();
this.$dom.off(`focus.${this.eventNS}`);
this.$dom.off(`blur.${this.eventNS}`);
this.$dom.off(`keydown.${this.eventNS}`);
this.$dom.off(`keyup.${this.eventNS}`);
this.dropInstance.destroy();
this.$menu.remove();
}
value() {
return this._value;
}
focus() {
this.$dom.focus();
}
}
assign(AutoComplete, DOMAttachedObject);
window.Hydro.components.autocomplete = AutoComplete;
@@ -0,0 +1,78 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { assign } from 'lodash';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import AutoCompleteFC from './components/AutoComplete';
export default class AutoComplete extends DOMAttachedObject {
static DOMAttachKey = 'ucwAutoCompleteInstance';
constructor($dom, options = {}) {
super($dom);
this.options = {
items: async () => [],
render: () => '',
text: () => null,
multi: false,
...options,
};
this.ref = null;
this.onChange = this.onChange.bind(this);
this.container = document.createElement('div');
this.$dom.addClass('autocomplete-dummy').after(this.container);
setTimeout(() => this.attach(), 0);
}
clear(clearValue = true) {
if (!this.ref) return;
if (clearValue) this.ref.clear();
else this.ref.closeList();
}
onChange(val) {
this.$dom.val(val);
}
attach() {
const value = this.$dom.val();
ReactDOM.render(
<AutoCompleteFC
ref={(ref) => { this.ref = ref; }}
height="34px"
itemsFn={this.options.items}
renderItem={this.options.render}
itemText={this.options.text}
defaultItems={value}
onChange={this.onChange}
multi={this.options.multi}
freeSolo={this.options.multi}
/>,
this.container,
);
}
open() {
if (!this.ref) return;
this.ref.triggerQuery();
}
close() {
if (!this.ref) return;
this.ref.closeList();
}
detach() {
if (this.detached) return;
super.detach();
ReactDOM.unmountComponentAtNode(this.container);
this.$dom.removeClass('autocomplete-dummy');
this.container.parentNode.removeChild(this.container);
}
focus() {
this.ref.focus();
}
}
assign(AutoComplete, DOMAttachedObject);
window.Hydro.components.autocomplete = AutoComplete;
@@ -1,6 +1,6 @@
.menu.problem-select
width: rem(200px)
.problem-select__uid
.problem-select__id
font-size: rem($font-size-small)
color: $userselect-uid-color
+84 -82
View File
@@ -7,100 +7,102 @@
<form method="post">
<div class="row">
{{ form.form_select({
columns:3,
label:'Rule',
name:'rule',
options:rules,
value:tdoc['rule']|default(''),
row:false
}) }}
columns:3,
label:'Rule',
name:'rule',
options:rules,
value:tdoc['rule']|default(''),
row:false
}) }}
{{ form.form_text({
columns:4,
label:'Title',
name:'title',
placeholder:_('title'),
value:tdoc.title|default(''),
autofocus:true,
row:false
}) }}
columns:4,
label:'Title',
name:'title',
placeholder:_('title'),
value:tdoc.title|default(''),
autofocus:true,
row:false
}) }}
{{ form.form_text({
columns:3,
label:'Invitation Code',
name:'code',
placeholder:_('(leave blank if none)'),
value:tdoc._code|default(''),
row:false
}) }}
columns:3,
label:'Invitation Code',
name:'code',
placeholder:_('(leave blank if none)'),
value:tdoc._code|default(''),
row:false
}) }}
{{ form.form_checkbox({
columns:2,
label:'Rated',
name:'rated',
placeholder:_('Is rated'),
value:tdoc.rated|default(true),
row:false
}) }}
</div><div class="row">
columns:2,
label:'Rated',
name:'rated',
placeholder:_('Is rated'),
value:tdoc.rated|default(true),
row:false
}) }}
</div>
<div class="row">
{{ form.form_text({
columns:4,
label:'Begin Date',
name:'beginAtDate',
placeholder:'YYYY-mm-dd',
value:date_text,
date:true,
row:false
}) }}
columns:4,
label:'Begin Date',
name:'beginAtDate',
placeholder:'YYYY-mm-dd',
value:date_text,
date:true,
row:false
}) }}
{{ form.form_text({
columns:4,
label:'Begin Time',
name:'beginAtTime',
placeholder:'HH:MM',
value:time_text,
time:true,
row:false
}) }}
columns:4,
label:'Begin Time',
name:'beginAtTime',
placeholder:'HH:MM',
value:time_text,
time:true,
row:false
}) }}
{{ form.form_text({
columns:4,
label:'Duration (hours)',
name:'duration',
value:duration|default('3'),
row:false
}) }}
columns:4,
label:'Duration (hours)',
name:'duration',
value:duration|default('3'),
row:false
}) }}
</div>
{{ form.form_text({
columns:null,
label:'Problems',
name:'pids',
value:pids,
noAutocomplete:true,
placeholder:_("Seperated with ','")
}) }}
columns:null,
label:'Problems',
name:'pids',
value:pids,
placeholder:_("Seperated with ','")
}) }}
{{ form.form_textarea({
columns:null,
label:'Content',
name:'content',
value:tdoc['content']|default(''),
markdown:true
}) }}
<div class="row"><div class="columns">
<input type="hidden" name="csrfToken" value="{{ handler.csrfToken }}">
{% if page_name == 'contest_edit' %}
<button type="submit" class="rounded primary button">
{{ _('Update') }}
</button>
{% if handler.user.own(tdoc) or handler.user.hasPerm(perm.PERM_EDIT_CONTEST) %}
columns:null,
label:'Content',
name:'content',
value:tdoc['content']|default(''),
markdown:true
}) }}
<div class="row">
<div class="columns">
<input type="hidden" name="csrfToken" value="{{ handler.csrfToken }}">
{% if page_name == 'contest_edit' %}
<button type="submit" class="rounded primary button">
{{ _('Update') }}
</button>
{% if handler.user.own(tdoc) or handler.user.hasPerm(perm.PERM_EDIT_CONTEST) %}
<button name="operation" value="delete" type="submit" class="rounded button">
{{ _('Delete') }}
</button>
{% endif %}
{% else %}
<button type="submit" class="rounded primary button">
{{ _('Create') }}
</button>
{% endif %}
<button type="button" class="rounded button" onclick="window.history.go(-1)">
{{ _('Cancel') }}
</button>
</div></div>
{% endif %}
{% else %}
<button type="submit" class="rounded primary button">
{{ _('Create') }}
</button>
{% endif %}
<button type="button" class="rounded button" onclick="window.history.go(-1)">
{{ _('Cancel') }}
</button>
</div>
</div>
</form>
</div>
</div>