common.tsx 10 KB

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