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.
 
 
 

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