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.
 
 
 

285 regels
8.5 KiB

  1. import * as React from 'react';
  2. import { tailwind } from '@tesseract-design/web-base';
  3. import { useFallbackId } from '@modal-sh/react-utils';
  4. const { tw } = tailwind;
  5. const ToggleSwitchDerivedElementComponent = 'input' as const;
  6. /**
  7. * Derived HTML element of the {@link ToggleSwitch} component.
  8. */
  9. export type ToggleSwitchDerivedElement = HTMLElementTagNameMap[
  10. typeof ToggleSwitchDerivedElementComponent
  11. ];
  12. /**
  13. * Props of the {@link ToggleSwitch} component.
  14. */
  15. export interface ToggleSwitchProps extends Omit<React.InputHTMLAttributes<ToggleSwitchDerivedElement>, 'type' | 'size' | 'children'> {
  16. /**
  17. * Should the component occupy the whole width of its parent?
  18. */
  19. block?: boolean;
  20. /**
  21. * Complementary content of the component.
  22. */
  23. subtext?: React.ReactNode;
  24. /**
  25. * Is the component in an indeterminate state?
  26. */
  27. indeterminate?: boolean;
  28. /**
  29. * Label to display when the component is checked.
  30. */
  31. checkedLabel?: React.ReactNode;
  32. /**
  33. * Label to display when the component is unchecked.
  34. */
  35. uncheckedLabel?: React.ReactNode;
  36. }
  37. export const toggleSwitchPlugin: tailwind.PluginCreator = ({ addComponents }) => {
  38. addComponents({
  39. '.toggle-switch': {
  40. '& + label + label + label > :first-child': {
  41. appearance: 'none',
  42. cursor: 'pointer',
  43. position: 'relative',
  44. overflow: 'hidden',
  45. height: '1.5em',
  46. width: '3em',
  47. display: 'block',
  48. 'box-sizing': 'border-box',
  49. 'border-radius': '9999px',
  50. color: 'rgb(var(--color-primary))',
  51. },
  52. '&:checked + label + label + label > :first-child': {
  53. color: 'rgb(var(--color-tertiary))',
  54. },
  55. '&:focus + label + label + label > :first-child': {
  56. color: 'rgb(var(--color-secondary))',
  57. },
  58. '& + label:active + label + label > :first-child': {
  59. color: 'rgb(var(--color-tertiary))',
  60. },
  61. '& + label + label:active + label > :first-child': {
  62. color: 'rgb(var(--color-tertiary))',
  63. },
  64. '& + label + label + label:active > :first-child': {
  65. color: 'rgb(var(--color-tertiary))',
  66. },
  67. '& + label + label + label > :first-child > :first-child': {
  68. width: '100%',
  69. height: '100%',
  70. 'background-color': 'rgb(var(--color-primary) / 50%)',
  71. 'border-radius': '9999px',
  72. display: 'block',
  73. 'box-sizing': 'border-box',
  74. 'background-clip': 'content-box',
  75. padding: '0.25em',
  76. appearance: 'none',
  77. },
  78. '&:checked + label + label + label > :first-child > :first-child': {
  79. 'background-color': 'rgb(var(--color-tertiary) / 50%)',
  80. },
  81. '&:focus + label + label + label > :first-child > :first-child': {
  82. 'background-color': 'rgb(var(--color-secondary) / 50%)',
  83. },
  84. '& + label:active + label + label > :first-child > :first-child': {
  85. 'background-color': 'rgb(var(--color-tertiary) / 50%)',
  86. },
  87. '& + label + label:active + label > :first-child > :first-child': {
  88. 'background-color': 'rgb(var(--color-tertiary) / 50%)',
  89. },
  90. '& + label + label + label:active > :first-child > :first-child': {
  91. 'background-color': 'rgb(var(--color-tertiary) / 50%)',
  92. },
  93. '& + label + label + label > :first-child > :first-child > :first-child': {
  94. appearance: 'none',
  95. 'border-radius': '9999px',
  96. display: 'block',
  97. width: '100%',
  98. height: '100%',
  99. margin: '-0.25em',
  100. 'box-sizing': 'border-box',
  101. 'background-clip': 'content-box',
  102. },
  103. '& + label + label + label > :first-child > :first-child > :first-child > :first-child': {
  104. width: '1.5em',
  105. height: '1.5em',
  106. margin: '-0.25em 0 0 0',
  107. display: 'block',
  108. 'border-radius': '9999px',
  109. 'background-color': 'currentColor',
  110. appearance: 'none',
  111. 'aspect-ratio': '1 / 1',
  112. 'z-index': '1',
  113. position: 'relative',
  114. 'box-shadow': '-100000.5em 0 0 100000em rgb(var(--color-primary) / 50%)',
  115. },
  116. '&:checked + label + label + label > :first-child > :first-child > :first-child > :first-child': {
  117. 'margin-left': 'calc(100% - 1em)',
  118. 'box-shadow': '-100000.5em 0 0 100000em rgb(var(--color-tertiary) / 50%)',
  119. },
  120. '&:indeterminate + label + label + label > :first-child > :first-child > :first-child > :first-child': {
  121. 'margin-left': 'calc(50% - 0.5em)',
  122. },
  123. '&:focus + label + label + label > :first-child > :first-child > :first-child > :first-child': {
  124. 'box-shadow': '-100000.5em 0 0 100000em rgb(var(--color-secondary) / 50%)',
  125. },
  126. '& + label:active + label + label > :first-child > :first-child > :first-child > :first-child': {
  127. 'box-shadow': '-100000.5em 0 0 100000em rgb(var(--color-tertiary) / 50%)',
  128. },
  129. '& + label + label:active + label > :first-child > :first-child > :first-child > :first-child': {
  130. 'box-shadow': '-100000.5em 0 0 100000em rgb(var(--color-tertiary) / 50%)',
  131. },
  132. '& + label + label + label:active > :first-child > :first-child > :first-child > :first-child': {
  133. 'box-shadow': '-100000.5em 0 0 100000em rgb(var(--color-tertiary) / 50%)',
  134. },
  135. },
  136. });
  137. };
  138. /**
  139. * Component for toggling a Boolean value in an appearance of a toggle switch.
  140. */
  141. export const ToggleSwitch = React.forwardRef<ToggleSwitchDerivedElement, ToggleSwitchProps>((
  142. {
  143. uncheckedLabel,
  144. checkedLabel,
  145. block = false,
  146. id: idProp,
  147. className,
  148. subtext,
  149. style,
  150. indeterminate = false,
  151. ...etcProps
  152. },
  153. forwardedRef,
  154. ) => {
  155. const defaultRef = React.useRef<ToggleSwitchDerivedElement>(null);
  156. const ref = forwardedRef ?? defaultRef;
  157. const id = useFallbackId(idProp);
  158. React.useEffect(() => {
  159. if (typeof ref === 'function') {
  160. const defaultElement = defaultRef.current as ToggleSwitchDerivedElement;
  161. defaultElement.indeterminate = indeterminate;
  162. ref(defaultElement);
  163. return;
  164. }
  165. const element = ref.current as ToggleSwitchDerivedElement;
  166. element.indeterminate = indeterminate;
  167. }, [indeterminate, defaultRef, ref]);
  168. return (
  169. <div
  170. className={tw(
  171. 'gap-x-4 flex-wrap',
  172. block && 'flex',
  173. !block && 'inline-flex align-center',
  174. className,
  175. )}
  176. style={style}
  177. data-testid="base"
  178. >
  179. <ToggleSwitchDerivedElementComponent
  180. {...etcProps}
  181. ref={typeof ref === 'function' ? defaultRef : ref}
  182. type="checkbox"
  183. id={id}
  184. className="sr-only peer/radio toggle-switch"
  185. />
  186. <label
  187. htmlFor={id}
  188. className="peer/children order-3 cursor-pointer peer-disabled/radio:cursor-not-allowed"
  189. >
  190. <span
  191. data-testid="children"
  192. >
  193. {checkedLabel}
  194. </span>
  195. </label>
  196. <label
  197. htmlFor={id}
  198. className="peer/children order-1 cursor-pointer peer-disabled/radio:cursor-not-allowed"
  199. >
  200. {uncheckedLabel && (
  201. <>
  202. <span className="sr-only">
  203. {' / '}
  204. </span>
  205. <span
  206. data-testid="uncheckedLabel"
  207. >
  208. {uncheckedLabel}
  209. </span>
  210. </>
  211. )}
  212. </label>
  213. <label
  214. htmlFor={id}
  215. className={tw(
  216. 'order-2 block rounded-full ring-secondary/50 overflow-hidden gap-4 leading-none select-none cursor-pointer',
  217. 'peer-focus/radio:outline-0 peer-focus/radio:ring-4 peer-focus/radio:ring-secondary/50',
  218. 'active:ring-tertiary/50 active:ring-4',
  219. 'peer-active/children:ring-tertiary/50 peer-active/children:ring-4 peer-active/children:text-tertiary',
  220. 'peer-disabled/radio:opacity-50 peer-disabled/radio:cursor-not-allowed peer-disabled/radio:ring-0',
  221. 'text-primary peer-disabled/radio:text-primary peer-focus/radio:text-secondary peer-checked/radio:text-tertiary active:text-tertiary',
  222. !uncheckedLabel && '-ml-4',
  223. )}
  224. >
  225. <span>
  226. <span>
  227. <span>
  228. <span />
  229. </span>
  230. </span>
  231. </span>
  232. </label>
  233. {subtext && (
  234. <div
  235. className={tw(
  236. 'block w-full text-xs order-4',
  237. !uncheckedLabel && 'pl-16',
  238. uncheckedLabel && 'pt-2',
  239. )}
  240. data-testid="subtext"
  241. >
  242. {subtext}
  243. </div>
  244. )}
  245. </div>
  246. );
  247. });
  248. ToggleSwitch.displayName = 'ToggleSwitch';
  249. ToggleSwitch.defaultProps = {
  250. block: false,
  251. subtext: undefined,
  252. indeterminate: false,
  253. checkedLabel: undefined,
  254. uncheckedLabel: undefined,
  255. };