common.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. /**
  2. * Copyright (c) 2018-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
  3. *
  4. * @author David Sehnal <david.sehnal@gmail.com>
  5. */
  6. import * as React from 'react';
  7. import { Color } from '../../mol-util/color';
  8. import { PurePluginUIComponent } from '../base';
  9. import { IconName, Icon } from './icons';
  10. export class ControlGroup extends React.Component<{
  11. header: string,
  12. initialExpanded?: boolean,
  13. hideExpander?: boolean,
  14. hideOffset?: boolean,
  15. topRightIcon?: IconName,
  16. onHeaderClick?: () => void
  17. }, { isExpanded: boolean }> {
  18. state = { isExpanded: !!this.props.initialExpanded }
  19. headerClicked = () => {
  20. if (this.props.onHeaderClick) {
  21. this.props.onHeaderClick();
  22. } else {
  23. this.setState({ isExpanded: !this.state.isExpanded });
  24. }
  25. }
  26. render() {
  27. // TODO: customize header style (bg color, togle button etc)
  28. return <div className='msp-control-group-wrapper'>
  29. <div className='msp-control-group-header'>
  30. <button className='msp-btn msp-btn-block' onClick={this.headerClicked}>
  31. {!this.props.hideExpander && <Icon name={this.state.isExpanded ? 'collapse' : 'expand'} />}
  32. {this.props.topRightIcon && <Icon name={this.props.topRightIcon} style={{ position: 'absolute', right: '2px', top: 0 }} />}
  33. {this.props.header}
  34. </button>
  35. </div>
  36. {this.state.isExpanded && <div className={this.props.hideOffset ? '' : 'msp-control-offset'} style={{ display: this.state.isExpanded ? 'block' : 'none' }}>
  37. {this.props.children}
  38. </div>
  39. }
  40. </div>
  41. }
  42. }
  43. export interface TextInputProps<T> {
  44. className?: string,
  45. style?: React.CSSProperties,
  46. value: T,
  47. fromValue?(v: T): string,
  48. toValue?(s: string): T,
  49. // TODO: add error/help messages here?
  50. isValid?(s: string): boolean,
  51. onChange(value: T): void,
  52. onEnter?(): void,
  53. onBlur?(): void,
  54. delayMs?: number,
  55. blurOnEnter?: boolean,
  56. blurOnEscape?: boolean,
  57. isDisabled?: boolean,
  58. placeholder?: string
  59. }
  60. interface TextInputState {
  61. originalValue: string,
  62. value: string
  63. }
  64. function _id(x: any) { return x; }
  65. export class TextInput<T = string> extends PurePluginUIComponent<TextInputProps<T>, TextInputState> {
  66. private input = React.createRef<HTMLInputElement>();
  67. private delayHandle: any = void 0;
  68. private pendingValue: T | undefined = void 0;
  69. state = { originalValue: '', value: '' }
  70. onBlur = () => {
  71. this.setState({ value: '' + this.state.originalValue });
  72. if (this.props.onBlur) this.props.onBlur();
  73. }
  74. get isPending() { return typeof this.delayHandle !== 'undefined'; }
  75. clearTimeout() {
  76. if (this.isPending) {
  77. clearTimeout(this.delayHandle);
  78. this.delayHandle = void 0;
  79. }
  80. }
  81. raiseOnChange = () => {
  82. this.props.onChange(this.pendingValue!);
  83. this.pendingValue = void 0;
  84. }
  85. triggerChanged(formatted: string, converted: T) {
  86. this.clearTimeout();
  87. if (formatted === this.state.originalValue) return;
  88. if (this.props.delayMs) {
  89. this.pendingValue = converted;
  90. this.delayHandle = setTimeout(this.raiseOnChange, this.props.delayMs);
  91. } else {
  92. this.props.onChange(converted);
  93. }
  94. }
  95. onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  96. const value = e.target.value;
  97. if (this.props.isValid && !this.props.isValid(value)) {
  98. this.clearTimeout();
  99. this.setState({ value });
  100. return;
  101. }
  102. const converted = (this.props.toValue || _id)(value);
  103. const formatted = (this.props.fromValue || _id)(converted);
  104. this.setState({ value: formatted }, () => this.triggerChanged(formatted, converted));
  105. }
  106. onKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
  107. if (e.charCode === 27 || e.keyCode === 27 /* esc */) {
  108. if (this.props.blurOnEscape && this.input.current) {
  109. this.input.current.blur();
  110. }
  111. }
  112. }
  113. onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
  114. if (e.keyCode === 13 || e.charCode === 13 /* enter */) {
  115. if (this.isPending) {
  116. this.clearTimeout();
  117. this.raiseOnChange();
  118. }
  119. if (this.props.blurOnEnter && this.input.current) {
  120. this.input.current.blur();
  121. }
  122. if (this.props.onEnter) this.props.onEnter();
  123. }
  124. e.stopPropagation();
  125. }
  126. static getDerivedStateFromProps(props: TextInputProps<any>, state: TextInputState) {
  127. const value = props.fromValue ? props.fromValue(props.value) : props.value;
  128. if (value === state.originalValue) return null;
  129. return { originalValue: value, value };
  130. }
  131. render() {
  132. return <input type='text'
  133. className={this.props.className}
  134. style={this.props.style}
  135. ref={this.input}
  136. onBlur={this.onBlur}
  137. value={this.state.value}
  138. placeholder={this.props.placeholder}
  139. onChange={this.onChange}
  140. onKeyPress={this.props.onEnter || this.props.blurOnEnter || this.props.blurOnEscape ? this.onKeyPress : void 0}
  141. onKeyDown={this.props.blurOnEscape ? this.onKeyUp : void 0}
  142. disabled={!!this.props.isDisabled}
  143. />;
  144. }
  145. }
  146. // TODO: replace this with parametrized TextInput
  147. export class NumericInput extends React.PureComponent<{
  148. value: number,
  149. onChange: (v: number) => void,
  150. onEnter?: () => void,
  151. onBlur?: () => void,
  152. blurOnEnter?: boolean,
  153. isDisabled?: boolean,
  154. placeholder?: string
  155. }, { value: string }> {
  156. state = { value: '0' };
  157. input = React.createRef<HTMLInputElement>();
  158. onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  159. const value = +e.target.value;
  160. this.setState({ value: e.target.value }, () => {
  161. if (!Number.isNaN(value) && value !== this.props.value) {
  162. this.props.onChange(value);
  163. }
  164. });
  165. }
  166. onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
  167. if ((e.keyCode === 13 || e.charCode === 13)) {
  168. if (this.props.blurOnEnter && this.input.current) {
  169. this.input.current.blur();
  170. }
  171. if (this.props.onEnter) this.props.onEnter();
  172. }
  173. e.stopPropagation();
  174. }
  175. onBlur = () => {
  176. this.setState({ value: '' + this.props.value });
  177. if (this.props.onBlur) this.props.onBlur();
  178. }
  179. static getDerivedStateFromProps(props: { value: number }, state: { value: string }) {
  180. const value = +state.value;
  181. if (Number.isNaN(value) || value === props.value) return null;
  182. return { value: '' + props.value };
  183. }
  184. render() {
  185. return <input type='text'
  186. ref={this.input}
  187. onBlur={this.onBlur}
  188. value={this.state.value}
  189. placeholder={this.props.placeholder}
  190. onChange={this.onChange}
  191. onKeyPress={this.props.onEnter || this.props.blurOnEnter ? this.onKeyPress : void 0}
  192. disabled={!!this.props.isDisabled}
  193. />
  194. }
  195. }
  196. export class ExpandableGroup extends React.Component<{
  197. label: string,
  198. colorStripe?: Color,
  199. pivot: JSX.Element,
  200. controls: JSX.Element
  201. }, { isExpanded: boolean }> {
  202. state = { isExpanded: false };
  203. toggleExpanded = () => this.setState({ isExpanded: !this.state.isExpanded });
  204. render() {
  205. const { label, pivot, controls } = this.props;
  206. // TODO: fix the inline CSS
  207. return <>
  208. <div className='msp-control-row'>
  209. <span>
  210. {label}
  211. <button className='msp-btn-link msp-btn-icon msp-control-group-expander' onClick={this.toggleExpanded} title={`${this.state.isExpanded ? 'Less' : 'More'} options`}
  212. style={{ background: 'transparent', textAlign: 'left', padding: '0' }}>
  213. <Icon name={this.state.isExpanded ? 'minus' : 'plus'} style={{ display: 'inline-block' }} />
  214. </button>
  215. </span>
  216. <div>{pivot}</div>
  217. {this.props.colorStripe && <div className='msp-expandable-group-color-stripe' style={{ backgroundColor: Color.toStyle(this.props.colorStripe) }} /> }
  218. </div>
  219. {this.state.isExpanded && <div className='msp-control-offset'>
  220. {controls}
  221. </div>}
  222. </>;
  223. }
  224. }
  225. export function IconButton(props: {
  226. icon: IconName,
  227. isSmall?: boolean,
  228. onClick: (e: React.MouseEvent<HTMLButtonElement>) => void,
  229. title?: string,
  230. toggleState?: boolean,
  231. disabled?: boolean,
  232. customClass?: string,
  233. style?: React.CSSProperties,
  234. 'data-id'?: string,
  235. extraContent?: JSX.Element
  236. }) {
  237. let className = `msp-btn-link msp-btn-icon${props.isSmall ? '-small' : ''}${props.customClass ? ' ' + props.customClass : ''}`;
  238. if (typeof props.toggleState !== 'undefined') className += ` msp-btn-link-toggle-${props.toggleState ? 'on' : 'off'}`
  239. return <button className={className} onClick={props.onClick} title={props.title} disabled={props.disabled} data-id={props['data-id']} style={props.style}>
  240. <Icon name={props.icon} />
  241. {props.extraContent}
  242. </button>;
  243. }
  244. export class ButtonSelect extends React.PureComponent<{ label: string, onChange: (value: string) => void, disabled?: boolean }> {
  245. onChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
  246. e.preventDefault()
  247. this.props.onChange(e.target.value)
  248. e.target.value = '_'
  249. }
  250. render() {
  251. return <select value='_' onChange={this.onChange} disabled={this.props.disabled}>
  252. <option key='_' value='_'>{this.props.label}</option>
  253. {this.props.children}
  254. </select>
  255. }
  256. }
  257. export function Options(options: [string, string][]) {
  258. return options.map(([value, label]) => <option key={value} value={value}>{label}</option>)
  259. }
  260. export function SectionHeader(props: { icon?: IconName, title: string | JSX.Element, desc?: string}) {
  261. return <div className='msp-section-header'>
  262. {props.icon && <Icon name={props.icon} />}
  263. {props.title} <small>{props.desc}</small>
  264. </div>
  265. }
  266. export type ToggleButtonProps = {
  267. style?: React.CSSProperties,
  268. className?: string,
  269. disabled?: boolean,
  270. label: string | JSX.Element,
  271. title?: string,
  272. icon?: IconName,
  273. isSelected?: boolean,
  274. toggle: () => void
  275. }
  276. export class ToggleButton extends React.PureComponent<ToggleButtonProps> {
  277. onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
  278. e.currentTarget.blur();
  279. this.props.toggle();
  280. }
  281. render() {
  282. const props = this.props;
  283. const label = props.label;
  284. return <button onClick={this.onClick} title={this.props.title}
  285. disabled={props.disabled} style={props.style} className={props.className}>
  286. <Icon name={this.props.icon} />
  287. {this.props.isSelected ? <b>{label}</b> : label}
  288. </button>;
  289. }
  290. }