Common front-end components for Web using the Tesseract design system, written for React. https://make.modal.sh/tesseract/web/react/common
 
 
 
 

378 lines
9.3 KiB

  1. import * as React from 'react'
  2. import * as PropTypes from 'prop-types'
  3. import styled from 'styled-components'
  4. import stringify from '../../services/stringify'
  5. import { Size, SizeMap } from '../../services/utilities'
  6. // TODO implement web-client text inputs!
  7. const MIN_HEIGHTS: SizeMap<string | number> = {
  8. small: '2.5rem',
  9. medium: '3rem',
  10. large: '4rem',
  11. }
  12. const LABEL_VERTICAL_PADDING_SIZES: SizeMap<string | number> = {
  13. small: '0.125rem',
  14. medium: '0.25rem',
  15. large: '0.5rem',
  16. }
  17. const VERTICAL_PADDING_SIZES: SizeMap<string | number> = {
  18. small: '0.6rem',
  19. medium: '0.85rem',
  20. large: '1.25rem',
  21. }
  22. const INPUT_FONT_SIZES: SizeMap<string | number> = {
  23. small: '0.85em',
  24. medium: '0.85em',
  25. large: '1em',
  26. }
  27. const SECONDARY_TEXT_SIZES: SizeMap<string | number> = {
  28. small: '0.65em',
  29. medium: '0.75em',
  30. large: '0.85em',
  31. }
  32. const ComponentBase = styled('div')({
  33. position: 'relative',
  34. borderRadius: '0.25rem',
  35. fontFamily: 'var(--font-family-base, sans-serif)',
  36. maxWidth: '100%',
  37. ':focus-within': {
  38. '--color-accent': 'var(--color-active, Highlight)',
  39. },
  40. })
  41. ComponentBase.displayName = 'div'
  42. const CaptureArea = styled('label')({
  43. display: 'block',
  44. borderRadius: 'inherit',
  45. overflow: 'hidden',
  46. })
  47. CaptureArea.displayName = 'label'
  48. const LabelWrapper = styled('span')({
  49. color: 'var(--color-accent, blue)',
  50. boxSizing: 'border-box',
  51. position: 'absolute',
  52. top: 0,
  53. left: 0,
  54. fontSize: '0.85em',
  55. maxWidth: '100%',
  56. overflow: 'hidden',
  57. textOverflow: 'ellipsis',
  58. whiteSpace: 'nowrap',
  59. fontWeight: 'bolder',
  60. zIndex: 2,
  61. pointerEvents: 'none',
  62. transitionProperty: 'color',
  63. lineHeight: 1,
  64. userSelect: 'none',
  65. })
  66. LabelWrapper.displayName = 'span'
  67. const Border = styled('span')({
  68. borderColor: 'var(--color-accent, blue)',
  69. boxSizing: 'border-box',
  70. display: 'inline-block',
  71. borderWidth: '0.125rem',
  72. borderStyle: 'solid',
  73. position: 'absolute',
  74. top: 0,
  75. left: 0,
  76. width: '100%',
  77. height: '100%',
  78. borderRadius: 'inherit',
  79. zIndex: 2,
  80. pointerEvents: 'none',
  81. transitionProperty: 'border-color',
  82. '::before': {
  83. position: 'absolute',
  84. top: 0,
  85. left: 0,
  86. width: '100%',
  87. height: '100%',
  88. content: "''",
  89. borderRadius: '0.125rem',
  90. opacity: 0.5,
  91. pointerEvents: 'none',
  92. },
  93. [`${ComponentBase}:focus-within &::before`]: {
  94. boxShadow: '0 0 0 0.375rem var(--color-accent, blue)',
  95. },
  96. })
  97. const Input = styled('input')({
  98. display: 'block',
  99. backgroundColor: 'var(--color-bg, white)',
  100. color: 'var(--color-fg, black)',
  101. verticalAlign: 'top',
  102. paddingTop: 0,
  103. paddingBottom: 0,
  104. boxSizing: 'border-box',
  105. position: 'relative',
  106. border: 0,
  107. borderRadius: 'inherit',
  108. margin: 0,
  109. font: 'inherit',
  110. minHeight: '4rem',
  111. minWidth: '8rem',
  112. maxWidth: '100%',
  113. width: '100%',
  114. zIndex: 1,
  115. transitionProperty: 'background-color, color',
  116. ':focus': {
  117. outline: 0,
  118. color: 'var(--color-fg, black)',
  119. },
  120. ':disabled': {
  121. cursor: 'not-allowed',
  122. },
  123. })
  124. Input.displayName = 'input'
  125. const TextArea = styled('textarea')({
  126. display: 'block',
  127. backgroundColor: 'var(--color-bg, white)',
  128. color: 'var(--color-fg, black)',
  129. verticalAlign: 'top',
  130. width: '100%',
  131. boxSizing: 'border-box',
  132. position: 'relative',
  133. border: 0,
  134. borderRadius: 'inherit',
  135. margin: 0,
  136. font: 'inherit',
  137. minHeight: '4rem',
  138. minWidth: '3rem',
  139. maxWidth: '100%',
  140. zIndex: 1,
  141. transitionProperty: 'background-color, color',
  142. ':focus': {
  143. outline: 0,
  144. },
  145. })
  146. TextArea.displayName = 'textarea'
  147. const HintWrapper = styled('span')({
  148. boxSizing: 'border-box',
  149. position: 'absolute',
  150. left: 0,
  151. fontSize: '0.85em',
  152. opacity: 0.5,
  153. maxWidth: '100%',
  154. overflow: 'hidden',
  155. textOverflow: 'ellipsis',
  156. whiteSpace: 'nowrap',
  157. zIndex: 2,
  158. pointerEvents: 'none',
  159. lineHeight: 1,
  160. userSelect: 'none',
  161. })
  162. const IndicatorWrapper = styled('span')({
  163. color: 'var(--color-accent, blue)',
  164. boxSizing: 'border-box',
  165. position: 'absolute',
  166. bottom: 0,
  167. right: 0,
  168. display: 'grid',
  169. placeContent: 'center',
  170. padding: '0 1rem',
  171. zIndex: 1,
  172. pointerEvents: 'none',
  173. transitionProperty: 'color',
  174. lineHeight: 1,
  175. userSelect: 'none',
  176. })
  177. const propTypes = {
  178. /**
  179. * Short textual description indicating the nature of the component's value.
  180. */
  181. label: PropTypes.any,
  182. /**
  183. * Short textual description as guidelines for valid input values.
  184. */
  185. hint: PropTypes.any,
  186. /**
  187. * Size of the component.
  188. */
  189. size: PropTypes.oneOf<Size>(['small', 'medium', 'large']),
  190. /**
  191. * Additional description, usually graphical, indicating the nature of the component's value.
  192. */
  193. indicator: PropTypes.node,
  194. /**
  195. * Should the component accept multiple lines of input?
  196. */
  197. multiline: PropTypes.bool,
  198. /**
  199. * Is the component active?
  200. */
  201. disabled: PropTypes.bool,
  202. /**
  203. * Should the component resize itself to show all its value?
  204. */
  205. autoResize: PropTypes.bool,
  206. /**
  207. * Placeholder of the component when there is no value.
  208. */
  209. placeholder: PropTypes.string,
  210. /**
  211. * How many rows should the component display if it accepts multiline input?
  212. */
  213. rows: PropTypes.number,
  214. /**
  215. * Does the button display a border?
  216. */
  217. border: PropTypes.bool,
  218. /**
  219. * Event handler triggered when the component changes value.
  220. */
  221. onChange: PropTypes.func,
  222. /**
  223. * Event handler triggered when the component receives focus.
  224. */
  225. onFocus: PropTypes.func,
  226. /**
  227. * Event handler triggered when the component loses focus.
  228. */
  229. onBlur: PropTypes.func,
  230. /**
  231. * Should the component be displayed with an alternate appearance?
  232. */
  233. alternate: PropTypes.bool,
  234. }
  235. type Props = PropTypes.InferProps<typeof propTypes>
  236. /**
  237. * Component for inputting textual values.
  238. *
  239. * This component supports multiline input and adjusts its layout accordingly.
  240. */
  241. const TextInput = React.forwardRef<HTMLInputElement | HTMLTextAreaElement, Props>(
  242. (
  243. {
  244. label = '',
  245. hint = '',
  246. indicator = null,
  247. size = 'medium',
  248. multiline = false,
  249. disabled = false,
  250. autoResize = false,
  251. placeholder = '',
  252. rows = 3,
  253. border = false,
  254. onChange,
  255. onFocus,
  256. onBlur,
  257. alternate = false,
  258. ...etcProps
  259. },
  260. ref,
  261. ) => (
  262. <ComponentBase
  263. style={{
  264. opacity: disabled ? 0.5 : undefined,
  265. }}
  266. >
  267. {border && <Border />}
  268. <CaptureArea>
  269. <LabelWrapper
  270. style={{
  271. paddingLeft: alternate ? '0.5rem' : undefined,
  272. paddingTop: LABEL_VERTICAL_PADDING_SIZES[size!],
  273. paddingBottom: LABEL_VERTICAL_PADDING_SIZES[size!],
  274. paddingRight: indicator ? MIN_HEIGHTS[size!] : '0.5rem',
  275. fontSize: SECONDARY_TEXT_SIZES[size!],
  276. }}
  277. >
  278. {stringify(label)}
  279. </LabelWrapper>
  280. {stringify(label).length > 0 && ' '}
  281. {multiline && (
  282. <TextArea
  283. {...etcProps}
  284. onChange={onChange as React.ChangeEventHandler}
  285. onFocus={onFocus as React.FocusEventHandler}
  286. onBlur={onBlur as React.FocusEventHandler}
  287. placeholder={placeholder!}
  288. ref={ref as React.Ref<HTMLTextAreaElement>}
  289. disabled={disabled!}
  290. rows={rows!}
  291. style={{
  292. height: `calc(${MIN_HEIGHTS[size!]} * ${rows})`,
  293. fontSize: INPUT_FONT_SIZES[size!],
  294. minHeight: MIN_HEIGHTS[size!],
  295. paddingTop: alternate ? VERTICAL_PADDING_SIZES[size!] : `calc(${SECONDARY_TEXT_SIZES[size!]} * 2)`,
  296. paddingBottom: VERTICAL_PADDING_SIZES[size!],
  297. paddingRight: indicator ? MIN_HEIGHTS[size!] : (alternate ? '1rem' : undefined),
  298. paddingLeft: alternate ? '1rem' : undefined,
  299. }}
  300. />
  301. )}
  302. {!multiline && (
  303. <Input
  304. {...etcProps}
  305. onChange={onChange as React.ChangeEventHandler}
  306. onFocus={onFocus as React.FocusEventHandler}
  307. onBlur={onBlur as React.FocusEventHandler}
  308. placeholder={placeholder!}
  309. ref={ref as React.Ref<HTMLInputElement>}
  310. disabled={disabled!}
  311. style={{
  312. paddingLeft: alternate ? '1rem' : undefined,
  313. fontSize: INPUT_FONT_SIZES[size!],
  314. minHeight: MIN_HEIGHTS[size!],
  315. paddingTop: alternate ? undefined : `calc(${SECONDARY_TEXT_SIZES[size!]} * 2)`,
  316. paddingRight: indicator ? MIN_HEIGHTS[size!] : (alternate ? '1rem' : undefined),
  317. }}
  318. />
  319. )}
  320. </CaptureArea>
  321. {stringify(hint).length > 0 && ' '}
  322. {stringify(hint).length > 0 && (
  323. <HintWrapper
  324. style={{
  325. top: alternate ? undefined : '0.75rem',
  326. bottom: alternate ? 0 : undefined,
  327. paddingLeft: alternate ? '1rem' : undefined,
  328. paddingTop: LABEL_VERTICAL_PADDING_SIZES[size!],
  329. paddingBottom: LABEL_VERTICAL_PADDING_SIZES[size!],
  330. paddingRight: indicator ? MIN_HEIGHTS[size!] : '1rem',
  331. fontSize: SECONDARY_TEXT_SIZES[size!],
  332. }}
  333. >
  334. {stringify(hint)}
  335. </HintWrapper>
  336. )}
  337. {(indicator as PropTypes.ReactComponentLike) && (
  338. <IndicatorWrapper
  339. style={{
  340. width: MIN_HEIGHTS[size!],
  341. height: MIN_HEIGHTS[size!],
  342. }}
  343. >
  344. {indicator}
  345. </IndicatorWrapper>
  346. )}
  347. </ComponentBase>
  348. ),
  349. )
  350. TextInput.propTypes = propTypes
  351. TextInput.displayName = 'TextInput'
  352. export default TextInput