Common front-end components for Web using the Tesseract design system, written for React. https://make.modal.sh/tesseract/web/react/common
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

335 line
7.8 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. const MIN_HEIGHTS: SizeMap<string | number> = {
  7. small: '2.5rem',
  8. medium: '3rem',
  9. large: '4rem',
  10. }
  11. const LABEL_VERTICAL_PADDING_SIZES: SizeMap<string | number> = {
  12. small: '0.125rem',
  13. medium: '0.25rem',
  14. large: '0.5rem',
  15. }
  16. const VERTICAL_PADDING_SIZES: SizeMap<string | number> = {
  17. small: '0.6rem',
  18. medium: '0.85rem',
  19. large: '1.25rem',
  20. }
  21. const INPUT_FONT_SIZES: SizeMap<string | number> = {
  22. small: '0.85em',
  23. medium: '0.85em',
  24. large: '1em',
  25. }
  26. const SECONDARY_TEXT_SIZES: SizeMap<string | number> = {
  27. small: '0.65em',
  28. medium: '0.75em',
  29. large: '0.85em',
  30. }
  31. const ComponentBase = styled('div')({
  32. position: 'relative',
  33. borderRadius: '0.25rem',
  34. fontFamily: 'var(--font-family-base, sans-serif)',
  35. maxWidth: '100%',
  36. ':focus-within': {
  37. '--color-accent': 'var(--color-active, Highlight)',
  38. },
  39. })
  40. ComponentBase.displayName = 'div'
  41. const CaptureArea = styled('label')({
  42. display: 'block',
  43. borderRadius: 'inherit',
  44. overflow: 'hidden',
  45. })
  46. CaptureArea.displayName = 'label'
  47. const LabelWrapper = styled('span')({
  48. color: 'var(--color-accent, blue)',
  49. boxSizing: 'border-box',
  50. position: 'absolute',
  51. top: 0,
  52. left: 0,
  53. paddingLeft: '0.5rem',
  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. paddingLeft: '1rem',
  109. margin: 0,
  110. font: 'inherit',
  111. minHeight: '4rem',
  112. minWidth: '8rem',
  113. maxWidth: '100%',
  114. width: '100%',
  115. zIndex: 1,
  116. transitionProperty: 'background-color, color',
  117. ':focus': {
  118. outline: 0,
  119. color: 'var(--color-fg, black)',
  120. },
  121. ':disabled': {
  122. cursor: 'not-allowed',
  123. },
  124. })
  125. Input.displayName = 'input'
  126. const TextArea = styled('textarea')({
  127. display: 'block',
  128. backgroundColor: 'var(--color-bg, white)',
  129. color: 'var(--color-fg, black)',
  130. verticalAlign: 'top',
  131. width: '100%',
  132. boxSizing: 'border-box',
  133. position: 'relative',
  134. border: 0,
  135. borderRadius: 'inherit',
  136. paddingLeft: '1rem',
  137. margin: 0,
  138. font: 'inherit',
  139. minHeight: '4rem',
  140. minWidth: '3rem',
  141. maxWidth: '100%',
  142. zIndex: 1,
  143. transitionProperty: 'background-color, color',
  144. ':focus': {
  145. outline: 0,
  146. },
  147. })
  148. TextArea.displayName = 'textarea'
  149. const HintWrapper = styled('span')({
  150. boxSizing: 'border-box',
  151. position: 'absolute',
  152. bottom: 0,
  153. left: 0,
  154. paddingLeft: '1rem',
  155. fontSize: '0.85em',
  156. opacity: 0.5,
  157. maxWidth: '100%',
  158. overflow: 'hidden',
  159. textOverflow: 'ellipsis',
  160. whiteSpace: 'nowrap',
  161. zIndex: 2,
  162. pointerEvents: 'none',
  163. lineHeight: 1,
  164. userSelect: 'none',
  165. })
  166. const IndicatorWrapper = styled('span')({
  167. color: 'var(--color-accent, blue)',
  168. boxSizing: 'border-box',
  169. position: 'absolute',
  170. bottom: 0,
  171. right: 0,
  172. display: 'grid',
  173. placeContent: 'center',
  174. padding: '0 1rem',
  175. zIndex: 1,
  176. pointerEvents: 'none',
  177. transitionProperty: 'color',
  178. lineHeight: 1,
  179. userSelect: 'none',
  180. })
  181. const propTypes = {
  182. /**
  183. * Short textual description indicating the nature of the component's value.
  184. */
  185. label: PropTypes.any,
  186. /**
  187. * Short textual description as guidelines for valid input values.
  188. */
  189. hint: PropTypes.any,
  190. /**
  191. * Size of the component.
  192. */
  193. size: PropTypes.oneOf<Size>(['small', 'medium', 'large']),
  194. /**
  195. * Additional description, usually graphical, indicating the nature of the component's value.
  196. */
  197. indicator: PropTypes.node,
  198. /**
  199. * Should the component accept multiple lines of input?
  200. */
  201. multiline: PropTypes.bool,
  202. /**
  203. * Is the component active?
  204. */
  205. disabled: PropTypes.bool,
  206. /**
  207. * Should the component resize itself to show all its value?
  208. */
  209. autoResize: PropTypes.bool,
  210. /**
  211. * Placeholder of the component when there is no value.
  212. */
  213. placeholder: PropTypes.string,
  214. /**
  215. * How many rows should the component display if it accepts multiline input?
  216. */
  217. rows: PropTypes.number,
  218. }
  219. type Props = PropTypes.InferProps<typeof propTypes>
  220. const TextInput = React.forwardRef<HTMLInputElement | HTMLTextAreaElement, Props>(
  221. (
  222. {
  223. label = '',
  224. hint = '',
  225. indicator = null,
  226. size = 'medium',
  227. multiline = false,
  228. disabled = false,
  229. autoResize = false,
  230. placeholder = '',
  231. rows = 3,
  232. },
  233. ref,
  234. ) => (
  235. <ComponentBase
  236. style={{
  237. opacity: disabled ? 0.5 : undefined,
  238. }}
  239. >
  240. <Border />
  241. <CaptureArea>
  242. <LabelWrapper
  243. style={{
  244. paddingTop: LABEL_VERTICAL_PADDING_SIZES[size!],
  245. paddingBottom: LABEL_VERTICAL_PADDING_SIZES[size!],
  246. paddingRight: indicator ? MIN_HEIGHTS[size!] : '0.5rem',
  247. fontSize: SECONDARY_TEXT_SIZES[size!],
  248. }}
  249. >
  250. {stringify(label)}
  251. </LabelWrapper>
  252. {stringify(label).length > 0 && ' '}
  253. {multiline && (
  254. <TextArea
  255. placeholder={placeholder!}
  256. ref={ref as React.Ref<HTMLTextAreaElement>}
  257. disabled={disabled!}
  258. rows={rows!}
  259. style={{
  260. height: `calc(${MIN_HEIGHTS[size!]} * ${rows})`,
  261. fontSize: INPUT_FONT_SIZES[size!],
  262. minHeight: MIN_HEIGHTS[size!],
  263. paddingTop: VERTICAL_PADDING_SIZES[size!],
  264. paddingBottom: VERTICAL_PADDING_SIZES[size!],
  265. paddingRight: indicator ? MIN_HEIGHTS[size!] : '1rem',
  266. }}
  267. />
  268. )}
  269. {!multiline && (
  270. <Input
  271. placeholder={placeholder!}
  272. ref={ref as React.Ref<HTMLInputElement>}
  273. disabled={disabled!}
  274. style={{
  275. fontSize: INPUT_FONT_SIZES[size!],
  276. minHeight: MIN_HEIGHTS[size!],
  277. paddingRight: indicator ? MIN_HEIGHTS[size!] : '1rem',
  278. }}
  279. />
  280. )}
  281. </CaptureArea>
  282. {stringify(hint).length > 0 && ' '}
  283. {stringify(hint).length > 0 && (
  284. <HintWrapper
  285. style={{
  286. paddingTop: LABEL_VERTICAL_PADDING_SIZES[size!],
  287. paddingBottom: LABEL_VERTICAL_PADDING_SIZES[size!],
  288. paddingRight: indicator ? MIN_HEIGHTS[size!] : '1rem',
  289. fontSize: SECONDARY_TEXT_SIZES[size!],
  290. }}
  291. >
  292. ({stringify(hint)})
  293. </HintWrapper>
  294. )}
  295. {(indicator as PropTypes.ReactComponentLike) && (
  296. <IndicatorWrapper
  297. style={{
  298. width: MIN_HEIGHTS[size!],
  299. height: MIN_HEIGHTS[size!],
  300. }}
  301. >
  302. {indicator}
  303. </IndicatorWrapper>
  304. )}
  305. </ComponentBase>
  306. ),
  307. )
  308. TextInput.propTypes = propTypes
  309. TextInput.displayName = 'TextInput'
  310. export default TextInput