Design system.
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.
 
 
 

269 lines
7.3 KiB

  1. import * as React from 'react';
  2. import { TextControl } from '@tesseract-design/web-base';
  3. import clsx from 'clsx';
  4. import { PluginCreator } from 'tailwindcss/types/config';
  5. import { useFallbackId } from '@modal-sh/react-utils';
  6. /**
  7. * Derived HTML element of the {@link MenuMultiSelect} component.
  8. */
  9. export type MenuMultiSelectDerivedElement = HTMLSelectElement;
  10. /**
  11. * Props of the {@link MenuMultiSelect} component.
  12. */
  13. export interface MenuMultiSelectProps extends Omit<React.HTMLProps<MenuMultiSelectDerivedElement>, 'size' | 'label' | 'multiple'> {
  14. /**
  15. * Short textual description indicating the nature of the component's value.
  16. */
  17. label?: React.ReactNode,
  18. /**
  19. * Short textual description as guidelines for valid input values.
  20. */
  21. hint?: React.ReactNode,
  22. /**
  23. * Size of the component.
  24. */
  25. size?: TextControl.Size,
  26. /**
  27. * Additional description, usually graphical, indicating the nature of the component's value.
  28. */
  29. indicator?: React.ReactNode,
  30. /**
  31. * Should the component display a border?
  32. */
  33. border?: boolean,
  34. /**
  35. * Should the component occupy the whole width of its parent?
  36. */
  37. block?: boolean,
  38. /**
  39. * Style of the component.
  40. */
  41. variant?: TextControl.Variant,
  42. /**
  43. * Is the label hidden?
  44. */
  45. hiddenLabel?: boolean,
  46. /**
  47. * Starting height of the component.
  48. */
  49. startingHeight?: number | string,
  50. }
  51. export const menuMultiSelectPlugin: PluginCreator = ({ addComponents }) => {
  52. addComponents({
  53. '.menu-multi-select': {
  54. '& optgroup': {
  55. 'color': 'rgb(var(--color-positive) / 50%)',
  56. 'text-transform': 'uppercase',
  57. 'font-size': '0.75em',
  58. 'margin-top': '0.5rem',
  59. 'user-select': 'none',
  60. },
  61. '& optgroup > option': {
  62. 'color': 'rgb(var(--color-positive))',
  63. 'text-transform': 'none',
  64. 'font-size': '1.333333em',
  65. },
  66. '& option': {
  67. 'user-select': 'none',
  68. },
  69. },
  70. });
  71. };
  72. /**
  73. * Component for inputting textual values.
  74. *
  75. * This component supports multiline input and adjusts its layout accordingly.
  76. */
  77. export const MenuMultiSelect = React.forwardRef<
  78. MenuMultiSelectDerivedElement,
  79. MenuMultiSelectProps
  80. >((
  81. {
  82. label,
  83. hint,
  84. indicator,
  85. size = 'medium' as const,
  86. border = false,
  87. block = false,
  88. variant = 'default' as const,
  89. hiddenLabel = false,
  90. className,
  91. startingHeight = '15rem',
  92. id: idProp,
  93. style,
  94. ...etcProps
  95. },
  96. forwardedRef,
  97. ) => {
  98. const labelId = React.useId();
  99. const id = useFallbackId(idProp);
  100. return (
  101. <div
  102. className={clsx(
  103. 'menu-multi-select relative rounded ring-secondary/50 group has-[:disabled]:opacity-50',
  104. 'focus-within:ring-4',
  105. {
  106. 'block': block,
  107. 'inline-block align-middle': !block,
  108. },
  109. className,
  110. )}
  111. data-testid="base"
  112. style={style}
  113. >
  114. {label && (
  115. <>
  116. <label
  117. data-testid="label"
  118. id={labelId}
  119. htmlFor={id}
  120. className={clsx(
  121. 'absolute z-[1] w-full top-0.5 left-0 pointer-events-none pl-1 text-xxs font-bold group-focus-within:text-secondary text-primary leading-none bg-negative select-none',
  122. {
  123. 'sr-only': hiddenLabel,
  124. },
  125. {
  126. 'pr-1': !indicator,
  127. },
  128. {
  129. 'pr-10': indicator && size === 'small',
  130. 'pr-12': indicator && size === 'medium',
  131. 'pr-16': indicator && size === 'large',
  132. },
  133. )}
  134. >
  135. <span className="block w-full whitespace-nowrap h-[1.1em] overflow-hidden text-ellipsis">
  136. {label}
  137. </span>
  138. </label>
  139. {' '}
  140. </>
  141. )}
  142. <select
  143. {...etcProps}
  144. ref={forwardedRef}
  145. id={id}
  146. aria-labelledby={labelId}
  147. data-testid="input"
  148. size={2}
  149. multiple
  150. style={{
  151. height: startingHeight,
  152. }}
  153. className={clsx(
  154. 'bg-negative rounded-inherit w-full peer block overflow-auto font-inherit cursor-pointer',
  155. 'focus:outline-0',
  156. 'disabled:opacity-50 disabled:cursor-not-allowed',
  157. {
  158. 'resize': !block,
  159. 'resize-y': block,
  160. },
  161. {
  162. 'text-xxs': size === 'small',
  163. 'text-xs': size === 'medium',
  164. },
  165. {
  166. 'pl-4': variant === 'default',
  167. 'pl-1.5': variant === 'alternate',
  168. },
  169. {
  170. 'pt-4': variant === 'alternate' && size === 'small',
  171. 'pt-5': variant === 'alternate' && size === 'medium',
  172. 'pt-8': variant === 'alternate' && size === 'large',
  173. },
  174. {
  175. 'py-2.5': variant === 'default' && size === 'small',
  176. 'py-3': variant === 'default' && size === 'medium',
  177. 'py-5': variant === 'default' && size === 'large',
  178. },
  179. {
  180. 'pr-4': variant === 'default' && !indicator,
  181. 'pr-1.5': variant === 'alternate' && !indicator,
  182. },
  183. {
  184. 'pr-10': indicator && size === 'small',
  185. 'pr-12': indicator && size === 'medium',
  186. 'pr-16': indicator && size === 'large',
  187. },
  188. {
  189. 'min-h-10': size === 'small',
  190. 'min-h-12': size === 'medium',
  191. 'min-h-16': size === 'large',
  192. },
  193. )}
  194. />
  195. {hint && (
  196. <div
  197. data-testid="hint"
  198. className={clsx(
  199. 'absolute left-0 px-1 pointer-events-none text-xxs leading-none w-full bg-negative select-none',
  200. {
  201. 'bottom-0 pl-4 pb-1': variant === 'default',
  202. 'top-0.5': variant === 'alternate',
  203. },
  204. {
  205. 'pt-2': variant === 'alternate' && size === 'small',
  206. 'pt-3': variant === 'alternate' && size !== 'small',
  207. },
  208. {
  209. 'pr-4': !indicator && variant === 'default',
  210. 'pr-1': !indicator && variant === 'alternate',
  211. },
  212. {
  213. 'pr-10': indicator && size === 'small',
  214. 'pr-12': indicator && size === 'medium',
  215. 'pr-16': indicator && size === 'large',
  216. },
  217. )}
  218. >
  219. <div
  220. className="opacity-50 whitespace-nowrap w-full h-[1.1em] overflow-hidden text-ellipsis"
  221. >
  222. {hint}
  223. </div>
  224. </div>
  225. )}
  226. {indicator && (
  227. <div
  228. data-testid="indicator"
  229. className={clsx(
  230. 'text-center flex items-center justify-center aspect-square absolute bottom-0 right-0 pointer-events-none select-none',
  231. {
  232. 'w-10': size === 'small',
  233. 'w-12': size === 'medium',
  234. 'w-16': size === 'large',
  235. },
  236. )}
  237. >
  238. {indicator}
  239. </div>
  240. )}
  241. {border && (
  242. <span
  243. data-testid="border"
  244. className="absolute z-[1] inset-0 rounded-inherit border-2 border-primary pointer-events-none peer-focus:border-secondary"
  245. />
  246. )}
  247. </div>
  248. );
  249. });
  250. MenuMultiSelect.displayName = 'MenuMultiSelect';
  251. MenuMultiSelect.defaultProps = {
  252. label: undefined,
  253. hint: undefined,
  254. indicator: undefined,
  255. size: 'medium',
  256. border: false,
  257. block: false,
  258. variant: 'default',
  259. hiddenLabel: false,
  260. startingHeight: '15rem',
  261. };