@bitrise/bitkit 13.224.0 → 13.225.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bitrise/bitkit",
3
3
  "description": "Bitrise React component library",
4
- "version": "13.224.0",
4
+ "version": "13.225.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+ssh://git@github.com/bitrise-io/bitkit.git"
@@ -9,6 +9,7 @@ export type DropdownEventArgs<T> = { value: T; index: number | undefined; label:
9
9
 
10
10
  type DropdownContext<T> = {
11
11
  formValue: T;
12
+ isMultiSelect: boolean;
12
13
  onOptionSelected: (arg: DropdownEventArgs<T>) => void;
13
14
  listRef: React.RefObject<(HTMLElement | null)[]>;
14
15
  searchValue: string;
@@ -4,6 +4,7 @@ import { DropdownProps } from './DropdownProps';
4
4
  type DropdownStateProps = Pick<DropdownProps<string | null>, 'disabled' | 'readOnly' | 'isWarning' | 'isError'> & {
5
5
  placeholder: boolean;
6
6
  isOpen: boolean;
7
+ isMultiSelect: boolean;
7
8
  hasAvatar: boolean;
8
9
  };
9
10
 
@@ -69,7 +70,7 @@ const getButtonContentColor = ({ disabled, placeholder }: DropdownStateProps) =>
69
70
 
70
71
  const DropdownTheme = {
71
72
  baseStyle: (props: DropdownStateProps) => {
72
- const { disabled, readOnly, hasAvatar, isError } = props;
73
+ const { disabled, isMultiSelect, readOnly, hasAvatar, isError } = props;
73
74
  return {
74
75
  field: {
75
76
  _hover:
@@ -131,7 +132,7 @@ const DropdownTheme = {
131
132
  cursor: 'pointer',
132
133
  paddingLeft: hasAvatar ? '12' : '16',
133
134
  paddingRight: '24',
134
- paddingY: '8',
135
+ paddingY: isMultiSelect ? '12' : '8',
135
136
  textAlign: 'start',
136
137
  userSelect: 'none',
137
138
  w: '100%',
@@ -164,28 +165,40 @@ const DropdownTheme = {
164
165
  };
165
166
  },
166
167
  sizes: {
167
- lg: ({ hasAvatar }: DropdownStateProps) => ({
168
- field: {
169
- fontSize: '3',
170
- gap: rem('8'),
171
- height: '48',
172
- paddingLeft: hasAvatar ? rem('12') : rem('16'),
173
- paddingRight: rem('16'),
174
- },
175
- }),
176
- md: {
177
- field: {
178
- fontSize: '2',
179
- gap: rem('8'),
180
- height: '40',
181
- paddingLeft: rem('12'),
182
- paddingRight: rem('12'),
183
- },
184
- item: {
185
- fontSize: '2',
186
- paddingLeft: rem('16'),
187
- paddingRight: rem('16'),
188
- },
168
+ lg: ({ hasAvatar, isMultiSelect }: DropdownStateProps) => {
169
+ return {
170
+ field: {
171
+ fontSize: '3',
172
+ gap: rem('8'),
173
+ height: isMultiSelect ? 'auto' : '48',
174
+ minH: isMultiSelect ? '48' : undefined,
175
+ paddingLeft: hasAvatar ? rem('12') : rem('16'),
176
+ paddingRight: rem('16'),
177
+ paddingTop: isMultiSelect ? rem('7') : '0',
178
+ paddingBottom: isMultiSelect ? rem('7') : '0',
179
+ },
180
+ };
181
+ },
182
+ md: ({ isMultiSelect }: DropdownStateProps) => {
183
+ return {
184
+ field: {
185
+ fontSize: '2',
186
+ gap: rem('8'),
187
+ height: isMultiSelect ? 'auto' : '40',
188
+ minH: isMultiSelect ? '40' : undefined,
189
+ paddingLeft: rem('12'),
190
+ paddingRight: rem('12'),
191
+ paddingTop: isMultiSelect ? rem('8') : '0',
192
+ paddingBottom: isMultiSelect ? rem('8') : '0',
193
+ },
194
+ item: {
195
+ fontSize: '2',
196
+ paddingLeft: rem('16'),
197
+ paddingRight: rem('16'),
198
+ paddingTop: isMultiSelect ? rem('8') : '0',
199
+ paddingBottom: isMultiSelect ? rem('8') : '0',
200
+ },
201
+ };
189
202
  },
190
203
  },
191
204
  };
@@ -1,8 +1,10 @@
1
1
  import React, {
2
2
  cloneElement,
3
+ createContext,
3
4
  forwardRef,
4
5
  ReactNode,
5
6
  useCallback,
7
+ useContext,
6
8
  useEffect,
7
9
  useId,
8
10
  useMemo,
@@ -24,6 +26,9 @@ import SearchInput from '../SearchInput/SearchInput';
24
26
  import FormLabel from '../Form/FormLabel';
25
27
  import { AvatarProps } from '../Avatar/Avatar';
26
28
  import { getDataAttributes } from '../../utils/utils';
29
+ import Tag from '../Tag/Tag';
30
+ import Box from '../Box/Box';
31
+ import IconButton from '../IconButton/IconButton';
27
32
  import { DropdownEventArgs, DropdownProvider, useDropdownContext, useDropdownStyles } from './Dropdown.context';
28
33
  import { DropdownDetailedOption, DropdownGroup, DropdownOption, DropdownOptionProps } from './DropdownOption';
29
34
  import DropdownButton from './DropdownButton';
@@ -32,6 +37,8 @@ import { NoResultsFound, useSimpleSearch } from './hooks/useSimpleSearch';
32
37
  import { isSearchable } from './isNodeMatch';
33
38
  import { DropdownProps } from './DropdownProps';
34
39
 
40
+ const MultiSelectContext = createContext(false);
41
+
35
42
  type DropdownSearchCustomProps = {
36
43
  value: string;
37
44
  onChange: (newValue: string) => void;
@@ -141,6 +148,7 @@ function findOption<T>(
141
148
  function useDropdown<T>({
142
149
  children,
143
150
  defaultValue,
151
+ disabled,
144
152
  dropdownWidth = 'match',
145
153
  name,
146
154
  onChange,
@@ -165,8 +173,11 @@ function useDropdown<T>({
165
173
  setActiveIndex,
166
174
  setSelectedIndex,
167
175
  } = useFloatingDropdown({ dropdownWidth, enabled: !readOnly, optionsRef, placement });
176
+
177
+ const isMultiSelect = useContext(MultiSelectContext);
178
+
168
179
  const [formValue, setFormValue] = useControllableState<T>({
169
- defaultValue,
180
+ defaultValue: isMultiSelect ? defaultValue || ([] as T) : defaultValue,
170
181
  onChange: (newValue) => onChange?.({ target: { name, value: newValue } }),
171
182
  value,
172
183
  });
@@ -195,7 +206,17 @@ function useDropdown<T>({
195
206
 
196
207
  const onOptionSelected = useCallback(
197
208
  (args: DropdownEventArgs<T>) => {
198
- setFormValue(args.value);
209
+ setFormValue((previous) => {
210
+ if (Array.isArray(previous)) {
211
+ if (previous.includes(args.value)) {
212
+ return previous.filter((aPrevious) => aPrevious !== args.value) as T;
213
+ }
214
+
215
+ return [...previous, args.value] as T;
216
+ }
217
+
218
+ return args.value;
219
+ });
199
220
  close();
200
221
  },
201
222
  [close, setFormValue],
@@ -205,6 +226,7 @@ function useDropdown<T>({
205
226
  activeIndex,
206
227
  formValue,
207
228
  getItemProps,
229
+ isMultiSelect,
208
230
  listRef,
209
231
  onOptionSelected,
210
232
  searchOnChange,
@@ -228,15 +250,84 @@ function useDropdown<T>({
228
250
  );
229
251
 
230
252
  useEffect(() => {
231
- const currentOption = findOption(refdChildren, formValue);
232
- if (currentOption) {
233
- setFormLabel(currentOption.label);
234
- setSelectedIndex(currentOption.index);
235
- setSelectedAvatar(currentOption.avatar);
236
- } else {
237
- setFormLabel(null);
253
+ if (Array.isArray(formValue)) {
254
+ setFormLabel(
255
+ formValue.length > 0 ? (
256
+ <Box alignItems="center" display="flex" gap={8}>
257
+ <Box display="flex" flexGrow={1} flexWrap="wrap" gap={8}>
258
+ {formValue
259
+ ?.sort((a, b) => {
260
+ if (typeof a === 'string' && typeof b === 'string') {
261
+ return a.localeCompare(b);
262
+ }
263
+
264
+ if (a === null) {
265
+ return -1;
266
+ }
267
+
268
+ return 1;
269
+ })
270
+ .map((formValueItem) => {
271
+ if (typeof formValueItem !== 'string' && formValueItem !== null) {
272
+ return <>{formValueItem}</>;
273
+ }
274
+
275
+ const currentOption = findOption(refdChildren, formValueItem);
276
+
277
+ return (
278
+ <Tag
279
+ colorScheme="neutral"
280
+ isDisabled={disabled}
281
+ key={formValueItem}
282
+ onClose={(event) => {
283
+ event.stopPropagation();
284
+
285
+ setFormValue((previous: T) => {
286
+ if (!Array.isArray(previous)) {
287
+ return previous;
288
+ }
289
+
290
+ return previous.filter((aPrevious) => aPrevious !== formValueItem) as T;
291
+ });
292
+ }}
293
+ size="sm"
294
+ >
295
+ {currentOption?.label || formValueItem}
296
+ </Tag>
297
+ );
298
+ })}
299
+ </Box>
300
+ <IconButton
301
+ aria-label="Clear all"
302
+ as="span"
303
+ color="icon/secondary"
304
+ iconName="Cross"
305
+ iconSize="24"
306
+ isDisabled={disabled}
307
+ onClick={(event) => {
308
+ event.stopPropagation();
309
+
310
+ setFormValue([] as T);
311
+ }}
312
+ size="sm"
313
+ variant="tertiary"
314
+ />
315
+ </Box>
316
+ ) : null,
317
+ );
238
318
  setSelectedIndex(null);
239
319
  setSelectedAvatar(undefined);
320
+ } else {
321
+ const currentOption = findOption(refdChildren, formValue);
322
+ if (currentOption) {
323
+ setFormLabel(currentOption.label);
324
+ setSelectedIndex(currentOption.index);
325
+ setSelectedAvatar(currentOption.avatar);
326
+ } else {
327
+ setFormLabel(null);
328
+ setSelectedIndex(null);
329
+ setSelectedAvatar(undefined);
330
+ }
240
331
  }
241
332
  }, [refdChildren, formValue]);
242
333
  return {
@@ -311,6 +402,7 @@ const Dropdown = forwardRef<Element, DropdownProps<string | null>>(
311
402
  ...rest
312
403
  } = useDropdown({
313
404
  defaultValue,
405
+ disabled,
314
406
  dropdownWidth,
315
407
  isWarning,
316
408
  name,
@@ -323,10 +415,12 @@ const Dropdown = forwardRef<Element, DropdownProps<string | null>>(
323
415
  value,
324
416
  ...props,
325
417
  });
418
+
326
419
  const dropdownStyles = useMultiStyleConfig('Dropdown', {
327
420
  hasAvatar: Boolean(avatar),
328
421
  disabled,
329
422
  isError,
423
+ isMultiSelect: useContext(MultiSelectContext),
330
424
  isOpen,
331
425
  isWarning,
332
426
  placeholder: !formLabel,
@@ -443,4 +537,16 @@ export function typedDropdown<T>() {
443
537
  };
444
538
  }
445
539
 
540
+ export const MultiSelectDropdown = (props: DropdownProps<(string | null)[]>) => {
541
+ const { Dropdown: TypedDropdown } = typedDropdown<(string | null)[]>();
542
+
543
+ const { children, ...rest } = props;
544
+
545
+ return (
546
+ <MultiSelectContext.Provider value>
547
+ <TypedDropdown {...rest}>{children}</TypedDropdown>
548
+ </MultiSelectContext.Provider>
549
+ );
550
+ };
551
+
446
552
  export default Dropdown;
@@ -5,6 +5,7 @@ import Text, { TextProps } from '../Text/Text';
5
5
  import Divider from '../Divider/Divider';
6
6
  import Box from '../Box/Box';
7
7
  import Icon from '../Icon/Icon';
8
+ import Checkbox from '../Form/Checkbox/Checkbox';
8
9
  import { useDropdownContext, useDropdownStyles } from './Dropdown.context';
9
10
 
10
11
  export type DropdownOptionProps<T> = {
@@ -27,7 +28,9 @@ const DropdownOption = <T = string,>({
27
28
  const { item } = useDropdownStyles();
28
29
  const ctx = useDropdownContext<T | null>();
29
30
  const { index } = rest as { index?: number };
30
- const isSelected = value === ctx.formValue;
31
+ const isSelected = !!(
32
+ ctx.formValue && (Array.isArray(ctx.formValue) ? ctx.formValue.includes(value) : ctx.formValue === value)
33
+ );
31
34
 
32
35
  return (
33
36
  <chakra.div
@@ -61,8 +64,17 @@ const DropdownOption = <T = string,>({
61
64
  >
62
65
  <Box display="flex" alignItems="center" gap="8">
63
66
  {avatar && <Avatar size={ctx.size === 'lg' ? '32' : '24'} {...avatar} />}
64
- <Box flex="1">{children}</Box>
65
- {isSelected && <Icon name="Check" color="icon/interactive" />}
67
+ {ctx.isMultiSelect ? (
68
+ <>
69
+ <Checkbox isChecked={isSelected} />
70
+ <Box flex="1">{children}</Box>
71
+ </>
72
+ ) : (
73
+ <>
74
+ <Box flex="1">{children}</Box>
75
+ {isSelected && <Icon name="Check" color="icon/interactive" />}
76
+ </>
77
+ )}
66
78
  </Box>
67
79
  </chakra.div>
68
80
  );
@@ -3,7 +3,7 @@ import Icon, { IconProps, TypeIconName } from '../Icon/Icon';
3
3
  import Tooltip, { TooltipProps } from '../Tooltip/Tooltip';
4
4
 
5
5
  export interface IconButtonProps extends ChakraIconButtonProps {
6
- as?: 'a' | 'button';
6
+ as?: 'a' | 'button' | 'span';
7
7
  disabled?: never;
8
8
  iconName: TypeIconName;
9
9
  isDanger?: boolean;
@@ -35,7 +35,7 @@ const IconButton = forwardRef<IconButtonProps, 'button'>((props, ref) => {
35
35
  ...rest
36
36
  } = props;
37
37
  const properties: ChakraIconButtonProps = {
38
- as: isDisabled ? 'button' : as,
38
+ as: as === 'a' && isDisabled ? 'button' : as,
39
39
  icon: <Icon name={iconName} size={iconSize || (size === 'lg' ? '24' : '16')} />,
40
40
  isDisabled,
41
41
  size,
@@ -19,7 +19,7 @@ export interface TagProps extends Omit<ChakraTagProps, 'colorScheme' | 'size' |
19
19
  iconName?: TypeIconName;
20
20
  isDisabled?: boolean;
21
21
  isLoading?: boolean;
22
- onClose?: () => void;
22
+ onClose?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
23
23
  size?: 'sm' | 'md';
24
24
  withIcon?: boolean;
25
25
  }
@@ -66,7 +66,7 @@ const Tag = forwardRef<TagProps, 'span'>((props, ref) => {
66
66
  </Text>
67
67
  {!!onClose && (
68
68
  <Tooltip isDisabled={!closeButtonTooltip} label={closeButtonTooltip}>
69
- <TagCloseButton isDisabled={isDisabled || isLoading} onClick={onClose}>
69
+ <TagCloseButton as="span" isDisabled={isDisabled || isLoading} onClick={onClose}>
70
70
  <Icon name="Cross" size="16" />
71
71
  </TagCloseButton>
72
72
  </Tooltip>