common.tsx 11 KB

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