index.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. "use client";
  2. import { Check } from "@phosphor-icons/react/dist/ssr/Check";
  3. import clsx from "clsx";
  4. import type { ComponentProps, ReactNode } from "react";
  5. import type { PopoverProps, Button as BaseButton } from "react-aria-components";
  6. import {
  7. Label,
  8. Select as BaseSelect,
  9. Popover,
  10. Header,
  11. Collection,
  12. SelectValue,
  13. } from "react-aria-components";
  14. import styles from "./index.module.scss";
  15. import type { Props as ButtonProps } from "../Button/index.jsx";
  16. import { Button } from "../Button/index.jsx";
  17. import { DropdownCaretDown } from "../DropdownCaretDown/index.jsx";
  18. import {
  19. ListBox,
  20. ListBoxItem,
  21. ListBoxSection,
  22. } from "../unstyled/ListBox/index.jsx";
  23. export type Props<T extends { id: string | number }> = Omit<
  24. ComponentProps<typeof BaseSelect>,
  25. "defaultSelectedKey" | "selectedKey" | "onSelectionChange"
  26. > &
  27. Pick<
  28. ButtonProps<typeof BaseButton>,
  29. "variant" | "size" | "rounded" | "hideText" | "isPending"
  30. > &
  31. Pick<PopoverProps, "placement"> & {
  32. show?: ((value: T) => ReactNode) | undefined;
  33. textValue?: ((value: T) => string) | undefined;
  34. icon?: ComponentProps<typeof Button>["beforeIcon"];
  35. label: ReactNode;
  36. hideLabel?: boolean | undefined;
  37. buttonLabel?: ReactNode;
  38. defaultButtonLabel?: ReactNode;
  39. } & (
  40. | {
  41. defaultSelectedKey?: T["id"] | undefined;
  42. }
  43. | {
  44. selectedKey: T["id"];
  45. onSelectionChange: (newValue: T["id"]) => void;
  46. }
  47. ) &
  48. (
  49. | {
  50. options: readonly T[];
  51. }
  52. | {
  53. hideGroupLabel?: boolean | undefined;
  54. optionGroups: {
  55. name: string;
  56. options: readonly T[];
  57. hideLabel?: boolean | undefined;
  58. }[];
  59. }
  60. );
  61. export const Select = <T extends { id: string | number }>({
  62. className,
  63. show,
  64. textValue,
  65. variant,
  66. size,
  67. rounded,
  68. hideText,
  69. icon,
  70. label,
  71. hideLabel,
  72. placement,
  73. isPending,
  74. buttonLabel,
  75. defaultButtonLabel,
  76. ...props
  77. }: Props<T>) => (
  78. // @ts-expect-error react-aria coerces everything to Key for some reason...
  79. <BaseSelect
  80. className={clsx(styles.select, className)}
  81. data-label-hidden={hideLabel ? "" : undefined}
  82. {...("selectedKey" in props && { selectedKey: props.selectedKey })}
  83. {...props}
  84. >
  85. <Label className={styles.label}>{label}</Label>
  86. <Button
  87. afterIcon={({ className }) => (
  88. <DropdownCaretDown className={clsx(styles.caret, className)} />
  89. )}
  90. variant={variant}
  91. size={size}
  92. rounded={rounded}
  93. hideText={hideText}
  94. beforeIcon={icon}
  95. isPending={isPending === true}
  96. >
  97. <ButtonLabel
  98. buttonLabel={buttonLabel}
  99. defaultButtonLabel={defaultButtonLabel}
  100. show={show}
  101. />
  102. </Button>
  103. <Popover
  104. {...(placement && { placement })}
  105. {...("optionGroups" in props && {
  106. "data-grouped": "",
  107. "data-group-label-hidden": props.hideGroupLabel ? "" : undefined,
  108. })}
  109. className={styles.popover ?? ""}
  110. >
  111. <span className={styles.title}>{label}</span>
  112. {"options" in props ? (
  113. <ListBox className={styles.listbox ?? ""} items={props.options}>
  114. {(item) => (
  115. <Item show={show} textValue={textValue}>
  116. {item}
  117. </Item>
  118. )}
  119. </ListBox>
  120. ) : (
  121. <ListBox className={styles.listbox ?? ""} items={props.optionGroups}>
  122. {({ name, options, hideLabel }) => (
  123. <ListBoxSection
  124. data-label-hidden={hideLabel ? "" : undefined}
  125. className={styles.section ?? ""}
  126. id={name}
  127. >
  128. <Header className={styles.groupLabel ?? ""}>{name}</Header>
  129. <Collection items={options}>
  130. {(item) => (
  131. <Item show={show} textValue={textValue}>
  132. {item}
  133. </Item>
  134. )}
  135. </Collection>
  136. </ListBoxSection>
  137. )}
  138. </ListBox>
  139. )}
  140. </Popover>
  141. </BaseSelect>
  142. );
  143. type ItemProps<T> = {
  144. children: T;
  145. show: ((value: T) => ReactNode) | undefined;
  146. textValue: ((value: T) => string) | undefined;
  147. };
  148. const Item = <T extends { id: string | number }>({
  149. children,
  150. show,
  151. textValue,
  152. }: ItemProps<T>) => (
  153. <ListBoxItem
  154. id={typeof children === "object" ? children.id : children}
  155. className={styles.listboxItem ?? ""}
  156. textValue={getTextValue({ children, show, textValue })}
  157. >
  158. <span>{show?.(children) ?? children.id}</span>
  159. <Check weight="bold" className={styles.check} />
  160. </ListBoxItem>
  161. );
  162. const getTextValue = <T extends { id: string | number }>({
  163. children,
  164. show,
  165. textValue,
  166. }: ItemProps<T>) => {
  167. if (textValue !== undefined) {
  168. return textValue(children);
  169. } else if (show === undefined) {
  170. return children.id.toString();
  171. } else {
  172. const result = show(children);
  173. return typeof result === "string" ? result : children.id.toString();
  174. }
  175. };
  176. type ButtonLabelProps<T extends { id: string | number }> = Pick<
  177. Props<T>,
  178. "buttonLabel" | "defaultButtonLabel" | "show"
  179. >;
  180. const ButtonLabel = <T extends { id: string | number }>({
  181. buttonLabel,
  182. defaultButtonLabel,
  183. show,
  184. }: ButtonLabelProps<T>) => {
  185. if (buttonLabel !== undefined && buttonLabel !== "") {
  186. return buttonLabel;
  187. } else if (defaultButtonLabel !== undefined && defaultButtonLabel !== "") {
  188. return (
  189. <SelectValue<T>>
  190. {(props) =>
  191. props.selectedText === null ? (
  192. defaultButtonLabel
  193. ) : (
  194. <SelectedValueLabel show={show} {...props} />
  195. )
  196. }
  197. </SelectValue>
  198. );
  199. } else {
  200. return (
  201. <SelectValue<T>>
  202. {(props) => <SelectedValueLabel show={show} {...props} />}
  203. </SelectValue>
  204. );
  205. }
  206. };
  207. type SelectedValueLabelProps<T extends { id: string | number }> = Pick<
  208. Props<T>,
  209. "show"
  210. > & {
  211. selectedItem: T | null;
  212. selectedText: string | null;
  213. };
  214. const SelectedValueLabel = <T extends { id: string | number }>({
  215. show,
  216. selectedItem,
  217. selectedText,
  218. }: SelectedValueLabelProps<T>) =>
  219. selectedItem ? (show?.(selectedItem) ?? selectedItem.id) : selectedText;