composite.tsx 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. import {Children, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import {FocusScope} from '@react-aria/focus';
  4. import {Item} from '@react-stately/collections';
  5. import {space} from 'sentry/styles/space';
  6. import {Control, ControlProps} from './control';
  7. import {List, MultipleListProps, SingleListProps} from './list';
  8. import {SelectOption} from './types';
  9. interface BaseCompositeSelectRegion<Value extends React.Key> {
  10. options: SelectOption<Value>[];
  11. key?: React.Key;
  12. label?: React.ReactNode;
  13. }
  14. /**
  15. * A single-selection (only one option can be selected at a time) "region" inside a
  16. * composite select. Each "region" is a separated, self-contained selectable list (each
  17. * renders as a `ul` with its own list state) whose selection values don't interfere
  18. * with one another.
  19. */
  20. export interface SingleCompositeSelectRegion<Value extends React.Key>
  21. extends BaseCompositeSelectRegion<Value>,
  22. Omit<
  23. SingleListProps<Value>,
  24. 'children' | 'items' | 'grid' | 'compositeIndex' | 'size'
  25. > {}
  26. /**
  27. * A multiple-selection (multiple options can be selected at the same time) "region"
  28. * inside a composite select. Each "region" is a separated, self-contained selectable
  29. * list (each renders as a `ul` with its own list state) whose selection values don't
  30. * interfere with one another.
  31. */
  32. export interface MultipleCompositeSelectRegion<Value extends React.Key>
  33. extends BaseCompositeSelectRegion<Value>,
  34. Omit<
  35. MultipleListProps<Value>,
  36. 'children' | 'items' | 'grid' | 'compositeIndex' | 'size'
  37. > {}
  38. /**
  39. * A "region" inside a composite select. Each "region" is a separated, self-contained
  40. * selectable list (each renders as a `ul` with its own list state) whose selection
  41. * values don't interfere with one another.
  42. */
  43. export type CompositeSelectRegion<Value extends React.Key> =
  44. | SingleCompositeSelectRegion<Value>
  45. | MultipleCompositeSelectRegion<Value>;
  46. /**
  47. * A React child inside CompositeSelect. This helps ensure that the only non-falsy child
  48. * allowed inside CompositeSelect is CompositeSelect.Region
  49. */
  50. type CompositeSelectChild =
  51. | React.ReactElement<CompositeSelectRegion<React.Key>>
  52. | false
  53. | null
  54. | undefined;
  55. export interface CompositeSelectProps extends ControlProps {
  56. /**
  57. * The "regions" inside this composite selector. Each region functions as a separated,
  58. * self-contained selectable list (each renders as a `ul` with its own list state)
  59. * whose values don't interfere with one another.
  60. */
  61. children: CompositeSelectChild | CompositeSelectChild[];
  62. /**
  63. * Whether to close the menu upon selection. This prop applies to the entire selector
  64. * and functions as a fallback value. Each composite region also accepts the same
  65. * prop, which will take precedence over this one.
  66. */
  67. closeOnSelect?: SingleListProps<React.Key>['closeOnSelect'];
  68. }
  69. /**
  70. * Flexible select component with a customizable trigger button
  71. */
  72. function CompositeSelect({
  73. children,
  74. // Control props
  75. grid,
  76. disabled,
  77. size = 'md',
  78. closeOnSelect,
  79. ...controlProps
  80. }: CompositeSelectProps) {
  81. return (
  82. <Control {...controlProps} grid={grid} size={size} disabled={disabled}>
  83. <FocusScope>
  84. <RegionsWrap>
  85. {Children.map(children, (child, index) => {
  86. if (!child) {
  87. return null;
  88. }
  89. return (
  90. <Region
  91. {...child.props}
  92. grid={grid}
  93. size={size}
  94. compositeIndex={index}
  95. closeOnSelect={child.props.closeOnSelect ?? closeOnSelect}
  96. />
  97. );
  98. })}
  99. </RegionsWrap>
  100. </FocusScope>
  101. </Control>
  102. );
  103. }
  104. /**
  105. * A "region" inside composite selectors. Each "region" is a separated, self-contained
  106. * selectable list (each renders as a `ul` with its own list state) whose selection
  107. * values don't interfere with one another.
  108. */
  109. CompositeSelect.Region = function <Value extends React.Key>(
  110. _props: CompositeSelectRegion<Value>
  111. ) {
  112. // This pseudo-component not meant to be rendered. It only functions as a props vessel
  113. // and composable child to `CompositeSelect`. `CompositeSelect` iterates over all child
  114. // instances of `CompositeSelect.Region` and renders `Region` with the specified props.
  115. return null;
  116. };
  117. export {CompositeSelect};
  118. type RegionProps<Value extends React.Key> = CompositeSelectRegion<Value> & {
  119. compositeIndex: SingleListProps<Value>['compositeIndex'];
  120. grid: SingleListProps<Value>['grid'];
  121. size: SingleListProps<Value>['size'];
  122. };
  123. function Region<Value extends React.Key>({
  124. options,
  125. value,
  126. defaultValue,
  127. onChange,
  128. multiple,
  129. disallowEmptySelection,
  130. isOptionDisabled,
  131. closeOnSelect,
  132. size,
  133. compositeIndex,
  134. label,
  135. ...props
  136. }: RegionProps<Value>) {
  137. // Combine list props into an object with two clearly separated types, one where
  138. // `multiple` is true and the other where it's not. Necessary to avoid TS errors.
  139. const listProps = useMemo(() => {
  140. if (multiple) {
  141. return {
  142. multiple,
  143. value,
  144. defaultValue,
  145. closeOnSelect,
  146. onChange,
  147. };
  148. }
  149. return {
  150. multiple,
  151. value,
  152. defaultValue,
  153. closeOnSelect,
  154. onChange,
  155. };
  156. }, [multiple, value, defaultValue, onChange, closeOnSelect]);
  157. const optionsWithKey = useMemo<SelectOption<Value>[]>(
  158. () => options.map(item => ({...item, key: item.value})),
  159. [options]
  160. );
  161. return (
  162. <List
  163. {...props}
  164. {...listProps}
  165. items={optionsWithKey}
  166. disallowEmptySelection={disallowEmptySelection}
  167. isOptionDisabled={isOptionDisabled}
  168. shouldFocusWrap={false}
  169. compositeIndex={compositeIndex}
  170. size={size}
  171. label={label}
  172. >
  173. {(opt: SelectOption<Value>) => (
  174. <Item key={opt.value} {...opt}>
  175. {opt.label}
  176. </Item>
  177. )}
  178. </List>
  179. );
  180. }
  181. const RegionsWrap = styled('div')`
  182. min-height: 0;
  183. overflow: auto;
  184. padding: ${space(0.5)} 0;
  185. /* Remove padding inside lists */
  186. > ul {
  187. padding: 0;
  188. }
  189. `;