@bitrise/bitkit 13.324.0 → 13.325.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.324.0",
4
+ "version": "13.325.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+ssh://git@github.com/bitrise-io/bitkit.git"
@@ -4,8 +4,8 @@ import { BadgeColorScheme, BadgeProps } from './Badge';
4
4
  const baseStyle: SystemStyleObject = {
5
5
  borderRadius: '4',
6
6
  display: 'inline-flex',
7
+ alignItems: 'center',
7
8
  gap: '4',
8
- padding: '4',
9
9
  textStyle: 'comp/badge/sm',
10
10
  };
11
11
 
@@ -87,15 +87,62 @@ const getVariant = (props: BadgeProps) => {
87
87
  return colors[variant || 'subtle'][colorScheme || 'neutral'];
88
88
  };
89
89
 
90
+ const sizes: Record<'xs' | 'xxs', SystemStyleObject> = {
91
+ xs: {
92
+ height: 'fit-content',
93
+ width: 'fit-content',
94
+ padding: '4px',
95
+ display: 'flex',
96
+ justifyContent: 'center',
97
+ alignItems: 'center',
98
+ '&.has-children': {
99
+ paddingInlineStart: '8px',
100
+ paddingInlineEnd: '8px',
101
+ },
102
+ '&.has-icon': {
103
+ paddingInlineStart: '4px',
104
+ },
105
+ '&.has-icon:not(.has-children)': {
106
+ paddingInlineEnd: '4px',
107
+ },
108
+ '&.single-char': {
109
+ width: '24px',
110
+ height: '24px',
111
+ padding: '4px 0',
112
+ },
113
+ },
114
+ xxs: {
115
+ height: 'fit-content',
116
+ width: 'fit-content',
117
+ display: 'flex',
118
+ justifyContent: 'center',
119
+ alignItems: 'center',
120
+ padding: '2px 6px 2px 4px',
121
+ '&.single-char:not(.has-icon)': {
122
+ width: '20px',
123
+ height: '20px',
124
+ padding: '2px 0',
125
+ },
126
+ '&.has-icon:not(.has-children)': {
127
+ padding: '2px',
128
+ },
129
+ '&.has-icon.has-children': {
130
+ padding: '2px 6px 2px 4px',
131
+ },
132
+ },
133
+ };
134
+
90
135
  const BadgeTheme = {
91
136
  baseStyle,
92
137
  variants: {
93
138
  bold: getVariant,
94
139
  subtle: getVariant,
95
140
  },
141
+ sizes,
96
142
  defaultProps: {
97
143
  variant: 'subtle',
98
144
  colorScheme: 'neutral',
145
+ size: 'xs',
99
146
  },
100
147
  };
101
148
 
@@ -11,22 +11,33 @@ export type BadgeColorScheme =
11
11
  | 'progress'
12
12
  | 'orange'
13
13
  | 'turquoise';
14
+
14
15
  export interface BadgeProps extends ChakraBadgeProps {
15
16
  children?: string | number;
16
17
  colorScheme?: BadgeColorScheme;
17
18
  iconName?: TypeIconName;
18
19
  variant?: 'bold' | 'subtle';
20
+ size?: 'xs' | 'xxs';
19
21
  }
20
22
 
21
23
  /**
22
24
  * Badges are used to highlight an item's status for quick recognition.
23
25
  */
24
26
  const Badge = forwardRef<BadgeProps, 'span'>((props, ref) => {
25
- const { children, iconName, ...rest } = props;
27
+ const { children, iconName, size = 'xs', className, ...rest } = props;
28
+
29
+ const hasIcon = !!iconName;
30
+ const hasChildren = !!children;
31
+ const isSingleChar = hasChildren && String(children).length === 1;
32
+
33
+ const classNames = [className, hasIcon && 'has-icon', hasChildren && 'has-children', isSingleChar && 'single-char']
34
+ .filter(Boolean)
35
+ .join(' ');
36
+
26
37
  return (
27
- <ChakraBadge {...rest} ref={ref} paddingInlineStart={iconName ? '4' : '8'} paddingInlineEnd={children ? '8' : '4'}>
28
- {!!iconName && <Icon size="16" name={iconName} />}
29
- {!!children && (
38
+ <ChakraBadge {...rest} size={size} ref={ref} className={classNames || undefined}>
39
+ {hasIcon && <Icon size="16" name={iconName} />}
40
+ {hasChildren && (
30
41
  <Text hasEllipsis as="span">
31
42
  {children}
32
43
  </Text>
@@ -94,7 +94,7 @@ export const FILTER_STORY_DATA: FilterData = {
94
94
  }, 2000);
95
95
  });
96
96
  },
97
- options: ['default 1', 'default 2', 'default 3'],
97
+ options: ['default 1', 'default 2', 'test case with a very long name that might overflow'],
98
98
  type: 'tag',
99
99
  },
100
100
  workflow: {
@@ -1,3 +1,4 @@
1
+ import Divider from '../../Divider/Divider';
1
2
  import Badge from '../../Badge/Badge';
2
3
  import Text from '../../Text/Text';
3
4
  import Button from '../../Button/Button';
@@ -6,12 +7,29 @@ import { useFilterContext } from '../Filter.context';
6
7
  import { FilterProps } from '../Filter.types';
7
8
  import FilterDrawer from './FilterDrawer';
8
9
  import FilterForm from './FilterForm';
10
+ import FilterSearch from './FilterSearch';
11
+
12
+ const Filter = (props: FilterProps) => {
13
+ const {
14
+ data: initialData,
15
+ defaultState,
16
+ filtersDependOn,
17
+ isLoading,
18
+ isMobile,
19
+ onChange,
20
+ searchTooltip,
21
+ showAdd = true,
22
+ showFilterIcon = true,
23
+ showClearFilters = true,
24
+ showSearch,
25
+ ...rest
26
+ } = props;
9
27
 
10
- const Filter = ({ isLoading, showAdd = true }: FilterProps) => {
11
28
  const {
12
29
  data,
13
30
  isPopoverOpen,
14
31
  onClearFilters,
32
+ onFilterChange,
15
33
  selectedCategory,
16
34
  setSelectedCategory,
17
35
  setPopoverOpen,
@@ -20,14 +38,14 @@ const Filter = ({ isLoading, showAdd = true }: FilterProps) => {
20
38
  } = useFilterContext();
21
39
 
22
40
  const isAllOptionForSwitch = (category: string) => {
23
- return data[category].type === 'switch' && state[category]?.[0] === 'all';
41
+ return data[category]?.type === 'switch' && state[category]?.[0] === 'all';
24
42
  };
25
43
 
26
44
  const count = Object.entries(state).filter(([name, item]) => item?.length > 0 && !isAllOptionForSwitch(name)).length;
27
45
 
28
46
  return (
29
- <>
30
- <Box alignItems="center" display="flex" justifyContent="space-between">
47
+ <Box display="flex" flexDirection="column" {...rest}>
48
+ <Box alignItems="center" display="flex" justifyContent="space-between" padding="12">
31
49
  <Button leftIconName="Filter" onClick={() => setPopoverOpen(true)} size="sm" variant="secondary">
32
50
  <Text alignItems="center" display="flex" gap="8">
33
51
  Filters
@@ -49,13 +67,20 @@ const Filter = ({ isLoading, showAdd = true }: FilterProps) => {
49
67
  </Button>
50
68
  )}
51
69
  </Box>
70
+ {showSearch && (
71
+ <>
72
+ <Divider color="border/minimal" />
73
+ <FilterSearch onChange={onFilterChange} value={state.search?.length ? state.search[0] : ''} />
74
+ <Divider color="border/minimal" />
75
+ </>
76
+ )}
52
77
  <FilterDrawer isOpen={isPopoverOpen} onClose={() => setPopoverOpen(false)} showAdd={showAdd} />
53
78
  <FilterForm
54
79
  isOpen={Boolean(selectedCategory)}
55
80
  onClose={() => setSelectedCategory(undefined)}
56
81
  selectedCategory={selectedCategory || ''}
57
82
  />
58
- </>
83
+ </Box>
59
84
  );
60
85
  };
61
86
 
@@ -97,8 +97,8 @@ const FilterForm = ({ isOpen, onClose, selectedCategory }: FilterFormProps) => {
97
97
  bodyPadding="0"
98
98
  bodyProps={{ overflowY: 'auto' }}
99
99
  bodyRef={bodyRef}
100
- contentProps={{ zIndex: 'dialog', top: '0' }}
101
- headerPadding={16}
100
+ contentProps={{ gap: '0', zIndex: 'dialog', top: '0' }}
101
+ headerPadding="12"
102
102
  hideCloseButton
103
103
  isOpen={isOpen}
104
104
  maxWidth="100%"
@@ -118,7 +118,7 @@ const FilterForm = ({ isOpen, onClose, selectedCategory }: FilterFormProps) => {
118
118
  <Text as="h6" color="text/tertiary" textStyle="heading/h6">
119
119
  Filter
120
120
  </Text>
121
- <Text as="h3" color="text/primary" textStyle="heading/mobile/h3">
121
+ <Text as="h3" color="text/primary" textStyle="heading/mobile/h3" textTransform="initial">
122
122
  {name}
123
123
  </Text>
124
124
  </Box>
@@ -162,21 +162,20 @@ const FilterForm = ({ isOpen, onClose, selectedCategory }: FilterFormProps) => {
162
162
  ) : null
163
163
  }
164
164
  >
165
- <Box display="flex" flexDirection="column" gap="16">
165
+ <Box display="flex" flexDirection="column" gap="8">
166
166
  <Box
167
167
  backgroundColor="background/primary"
168
168
  display="flex"
169
169
  flexDirection="column"
170
170
  gap="8"
171
- marginBottom="8"
172
171
  position="sticky"
173
172
  top="0"
174
173
  >
175
174
  {(withSearch || isAsync) && (
176
175
  <>
177
- <Divider />
176
+ <Divider color="border/minimal" />
178
177
  <SearchInput
179
- marginInline="12"
178
+ marginInline="8"
180
179
  onChange={onSearchChange}
181
180
  placeholder="Search for options"
182
181
  value={searchValue}
@@ -184,7 +183,7 @@ const FilterForm = ({ isOpen, onClose, selectedCategory }: FilterFormProps) => {
184
183
  />
185
184
  </>
186
185
  )}
187
- <Divider />
186
+ <Divider color="border/minimal" />
188
187
  </Box>
189
188
  <Box display="flex" flexDirection="column" gap="8">
190
189
  {isLoading && (
@@ -4,6 +4,7 @@ import FormLabel from '../../Form/FormLabel';
4
4
  import Button from '../../Button/Button';
5
5
  import Box from '../../Box/Box';
6
6
  import Icon from '../../Icon/Icon';
7
+ import Text from '../../Text/Text';
7
8
  import IconButton from '../../IconButton/IconButton';
8
9
  import { useFilterContext } from '../Filter.context';
9
10
  import { getDateRangeLabel, getOptionLabel } from '../Filter.utils';
@@ -59,7 +60,9 @@ const FilterItem = ({ category }: FilterItemProps) => {
59
60
  variant="secondary"
60
61
  width="100%"
61
62
  >
62
- {getText()}
63
+ <Text overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap" width="100%" textAlign="left" minW="0">
64
+ {getText()}
65
+ </Text>
63
66
  </Button>
64
67
  <IconButton
65
68
  aria-label="Clear"
@@ -88,7 +91,9 @@ const FilterItem = ({ category }: FilterItemProps) => {
88
91
  {getText()}
89
92
  </Box>
90
93
  ) : (
91
- getText()
94
+ <Text overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap" minW="0">
95
+ {getText()}
96
+ </Text>
92
97
  )}
93
98
  </Button>
94
99
  )}
@@ -0,0 +1,36 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { InputProps } from 'chakra-ui-2--react';
3
+ import { useDebounce } from '../../../utils/utils';
4
+ import SearchInput from '../../SearchInput/SearchInput';
5
+ import { FilterValue } from '../Filter.types';
6
+
7
+ export interface FilterSearchProps extends Omit<InputProps, 'onChange' | 'value'> {
8
+ onChange: (category: string, selected: FilterValue) => void;
9
+ value: string;
10
+ }
11
+
12
+ const FilterSearch = (props: FilterSearchProps) => {
13
+ const { onChange, value } = props;
14
+
15
+ const [searchValue, setSearchValue] = useState(value);
16
+
17
+ const onInputChange = (newSearchValue: string) => {
18
+ setSearchValue(newSearchValue);
19
+ };
20
+
21
+ const debouncedSearchValue = useDebounce<string>(searchValue, 500);
22
+
23
+ useEffect(() => {
24
+ onChange('search', debouncedSearchValue.length ? [debouncedSearchValue] : []);
25
+ }, [debouncedSearchValue]);
26
+
27
+ useEffect(() => {
28
+ setSearchValue(value);
29
+ }, [value]);
30
+
31
+ return (
32
+ <SearchInput onChange={onInputChange} paddingBlock="4" placeholder="Search" value={searchValue} variant="mobile" />
33
+ );
34
+ };
35
+
36
+ export default FilterSearch;
@@ -0,0 +1,14 @@
1
+ import { forwardRef, Icon, IconProps } from 'chakra-ui-2--react';
2
+
3
+ const FolderEmpty = forwardRef<IconProps, 'svg'>((props, ref) => (
4
+ <Icon ref={ref} viewBox="0 0 16 16" {...props}>
5
+ <path
6
+ fillRule="evenodd"
7
+ clipRule="evenodd"
8
+ d="M6.37868 2.5C6.7765 2.5 7.15804 2.65804 7.43934 2.93934L8 3.5H13.5C14.3284 3.5 15 4.17157 15 5V12C15 12.8284 14.3284 13.5 13.5 13.5H2.5C1.67157 13.5 1 12.8284 1 12V4C1 3.17157 1.67157 2.5 2.5 2.5H6.37868ZM7.37868 5L6.37868 4H2.5V12H13.5V5L7.37868 5Z"
9
+ fill="currentColor"
10
+ />
11
+ </Icon>
12
+ ));
13
+
14
+ export default FolderEmpty;
@@ -108,6 +108,7 @@ export { default as Filter } from './Filter';
108
108
  export { default as Flag } from './Flag';
109
109
  export { default as Flutter } from './Flutter';
110
110
  export { default as Folder } from './Folder';
111
+ export { default as FolderEmpty } from './FolderEmpty';
111
112
  export { default as Fullscreen } from './Fullscreen';
112
113
  export { default as FullscreenExit } from './FullscreenExit';
113
114
  export { default as Gauge } from './Gauge';
@@ -0,0 +1,14 @@
1
+ import { forwardRef, Icon, IconProps } from 'chakra-ui-2--react';
2
+
3
+ const FolderEmpty = forwardRef<IconProps, 'svg'>((props, ref) => (
4
+ <Icon ref={ref} viewBox="0 0 24 24" {...props}>
5
+ <path
6
+ fillRule="evenodd"
7
+ clipRule="evenodd"
8
+ d="M10.5858 4.58579C10.2107 4.21071 9.70201 4 9.17157 4H4C2.89543 4 2 4.89543 2 6V18C2 19.1046 2.89543 20 4 20H20C21.1046 20 22 19.1046 22 18V8C22 6.89543 21.1046 6 20 6H12L10.5858 4.58579ZM4 6V18H20V8H11.1716L9.17157 6H4Z"
9
+ fill="currentColor"
10
+ />
11
+ </Icon>
12
+ ));
13
+
14
+ export default FolderEmpty;
@@ -106,6 +106,7 @@ export { default as Filter } from './Filter';
106
106
  export { default as Flag } from './Flag';
107
107
  export { default as Flutter } from './Flutter';
108
108
  export { default as Folder } from './Folder';
109
+ export { default as FolderEmpty } from './FolderEmpty';
109
110
  export { default as Fullscreen } from './Fullscreen';
110
111
  export { default as FullscreenExit } from './FullscreenExit';
111
112
  export { default as Gauge } from './Gauge';
@@ -3,6 +3,7 @@ import { useControllableState } from 'chakra-ui-2--react';
3
3
  import { NodeCallback, TreeViewAction, TreeViewState } from './TreeView.types';
4
4
 
5
5
  const TreeViewContext = createContext<TreeViewState & TreeViewAction>({
6
+ variant: undefined,
6
7
  selectedId: undefined,
7
8
  expandedIds: new Set<string>(),
8
9
  disabledIds: new Set<string>(),
@@ -10,7 +11,7 @@ const TreeViewContext = createContext<TreeViewState & TreeViewAction>({
10
11
  onExpandedChange: () => undefined,
11
12
  });
12
13
 
13
- export type TreeViewContenxtProviderProps = PropsWithChildren<
14
+ export type TreeViewContextProviderProps = PropsWithChildren<
14
15
  Partial<TreeViewState> &
15
16
  Partial<TreeViewAction> & {
16
17
  defaultSelectedId?: string;
@@ -19,6 +20,7 @@ export type TreeViewContenxtProviderProps = PropsWithChildren<
19
20
  }
20
21
  >;
21
22
  export const TreeViewContextProvider = ({
23
+ variant = 'navigation',
22
24
  selectedId: controlledSelectedId,
23
25
  defaultSelectedId,
24
26
  expandedIds: controlledExpandedIds,
@@ -28,7 +30,7 @@ export const TreeViewContextProvider = ({
28
30
  onSelectionChange,
29
31
  onExpandedChange,
30
32
  children,
31
- }: TreeViewContenxtProviderProps) => {
33
+ }: TreeViewContextProviderProps) => {
32
34
  const [selectedId, setSelectedId] = useControllableState({
33
35
  value: controlledSelectedId,
34
36
  defaultValue: defaultSelectedId,
@@ -71,8 +73,15 @@ export const TreeViewContextProvider = ({
71
73
  );
72
74
 
73
75
  const contextValue = useMemo(
74
- () => ({ selectedId, expandedIds, disabledIds, onSelectionChange: handleSelect, onExpandedChange: handleExpand }),
75
- [selectedId, expandedIds, disabledIds, handleSelect, handleExpand],
76
+ () => ({
77
+ variant,
78
+ selectedId,
79
+ expandedIds,
80
+ disabledIds,
81
+ onSelectionChange: handleSelect,
82
+ onExpandedChange: handleExpand,
83
+ }),
84
+ [variant, selectedId, expandedIds, disabledIds, handleSelect, handleExpand],
76
85
  );
77
86
 
78
87
  return <TreeViewContext.Provider value={contextValue}>{children}</TreeViewContext.Provider>;
@@ -1,28 +1,63 @@
1
1
  import { createMultiStyleConfigHelpers } from 'chakra-ui-2--styled-system';
2
2
 
3
3
  const { defineMultiStyleConfig, definePartsStyle } = createMultiStyleConfigHelpers([
4
+ 'listItem',
4
5
  'icon',
6
+ 'iconWrapper',
5
7
  'button',
6
8
  'buttonContent',
7
9
  'textBlock',
8
10
  'suffixBlock',
9
11
  'selectionIndicator',
12
+ 'collapseContent',
13
+ 'hoverActionsWrapper',
14
+ 'hoverActionButton',
15
+ 'leafBadge',
10
16
  ]);
11
17
 
12
18
  const baseStyle = definePartsStyle({
19
+ listItem: {
20
+ overflow: 'hidden',
21
+ },
13
22
  icon: {
14
23
  marginTop: '10px',
15
24
  marginBottom: '2px',
16
25
  color: 'icon/primary',
17
26
  },
27
+ leafBadge: {
28
+ marginTop: '10px',
29
+ marginBottom: '2px',
30
+ },
31
+ iconWrapper: {
32
+ position: 'relative',
33
+ display: 'flex',
34
+ alignSelf: 'flex-start',
35
+ zIndex: 1,
36
+ '&.show-connector::after': {
37
+ content: '""',
38
+ position: 'absolute',
39
+ left: '50%',
40
+ top: '100%',
41
+ bottom: '-100px',
42
+ width: '1px',
43
+ backgroundColor: 'border/regular',
44
+ },
45
+ },
18
46
  button: {
19
47
  pos: 'relative',
20
48
  py: '0',
21
49
  px: '16',
22
50
  pl: 'calc(16px + 24px * var(--level))',
23
51
  w: '100%',
52
+ overflow: 'hidden',
24
53
  _hover: {
25
54
  bgColor: 'background/secondary',
55
+ '& .hover-actions': {
56
+ display: 'flex',
57
+ },
58
+ '& .hide-on-hover': {
59
+ display: 'none',
60
+ },
26
61
  },
27
62
  _active: {
28
63
  bgColor: 'background/tertiary',
@@ -37,6 +72,15 @@ const baseStyle = definePartsStyle({
37
72
  bgColor: 'background/selected-hover',
38
73
  },
39
74
  },
75
+ '&.show-ancestor-connectors::before': {
76
+ content: '""',
77
+ position: 'absolute',
78
+ top: 0,
79
+ bottom: 0,
80
+ left: '24px',
81
+ width: 'calc(var(--level) * 24px)',
82
+ zIndex: 1,
83
+ },
40
84
  },
41
85
  buttonContent: {
42
86
  gap: '8',
@@ -63,9 +107,17 @@ const baseStyle = definePartsStyle({
63
107
  display: 'flex',
64
108
  alignItems: 'center',
65
109
  justifyContent: 'flex-end',
66
- paddingTop: '8',
67
110
  paddingLeft: '16',
68
111
  gap: '8',
112
+ '& > .hide-on-hover': {
113
+ display: 'flex',
114
+ },
115
+ 'button:hover > div > div > &': {
116
+ pt: '0',
117
+ },
118
+ 'button:hover > div > div > & > .hide-on-hover': {
119
+ display: 'none',
120
+ },
69
121
  },
70
122
  selectionIndicator: {
71
123
  width: '3px',
@@ -75,6 +127,23 @@ const baseStyle = definePartsStyle({
75
127
  bgColor: 'border/selected',
76
128
  borderLeftRadius: '2',
77
129
  },
130
+ collapseContent: {
131
+ position: 'relative',
132
+ '&.show-connector::before': {
133
+ content: '""',
134
+ position: 'absolute',
135
+ top: 0,
136
+ bottom: 0,
137
+ left: 'calc(16px + var(--level) * 24px + 8px)',
138
+ width: '1px',
139
+ backgroundColor: 'border/regular',
140
+ zIndex: 1,
141
+ },
142
+ },
143
+ hoverActionsWrapper: {
144
+ display: 'none',
145
+ alignItems: 'center',
146
+ },
78
147
  });
79
148
 
80
149
  const TreeViewTheme = defineMultiStyleConfig({
@@ -1,81 +1,36 @@
1
1
  import { PropsWithChildren } from 'react';
2
- import { TreeViewContenxtProviderProps, TreeViewContextProvider } from './TreeView.context';
2
+ import { TreeViewContextProviderProps, TreeViewContextProvider } from './TreeView.context';
3
3
  import TreeViewGroup from './TreeViewGroup';
4
4
  import TreeViewNode from './TreeViewNode';
5
- import { NodeData } from './TreeView.types';
5
+ import { NodeData, TreeViewVariant, BranchNodeData, LeafNodeData } from './TreeView.types';
6
6
 
7
- const TreeViewLeaf = (props: Omit<NodeData, 'type' | 'children'>) => (
7
+ const TreeViewLeaf = (props: Omit<LeafNodeData, 'type' | 'children'>) => (
8
8
  <TreeViewNode type="leaf" level={-1} indexPath={[]} titlePath={[]} {...props} />
9
9
  );
10
- const TreeViewBranch = (props: PropsWithChildren<Omit<NodeData, 'type' | 'children'>>) => (
10
+ const TreeViewBranch = (props: PropsWithChildren<Omit<BranchNodeData, 'type' | 'children'>>) => (
11
11
  <TreeViewNode type="branch" level={-1} indexPath={[]} titlePath={[]} {...props} />
12
12
  );
13
13
 
14
14
  const renderTreeNodes = (nodes: NodeData[]) => {
15
15
  return nodes.map((node) => {
16
- const {
17
- id,
18
- type,
19
- title,
20
- description,
21
- iconName,
22
- iconColor,
23
- label,
24
- labelIconName,
25
- labelIconColor,
26
- children,
27
- onClick,
28
- } = node;
16
+ const { id, type, children, ...rest } = node;
29
17
 
30
18
  if (type === 'branch') {
31
19
  return (
32
- <TreeViewNode
33
- key={id}
34
- id={id}
35
- type="branch"
36
- title={title}
37
- description={description}
38
- iconName={iconName}
39
- iconColor={iconColor}
40
- label={label}
41
- labelIconName={labelIconName}
42
- labelIconColor={labelIconColor}
43
- // Thes props will be overridden by the TreeViewGroup
44
- level={-1}
45
- indexPath={[]}
46
- titlePath={[]}
47
- onClick={onClick}
48
- >
20
+ <TreeViewNode key={id} id={id} type="branch" level={-1} indexPath={[]} titlePath={[]} {...rest}>
49
21
  {children && renderTreeNodes(children)}
50
22
  </TreeViewNode>
51
23
  );
52
24
  }
53
25
 
54
- return (
55
- <TreeViewNode
56
- key={id}
57
- id={id}
58
- type="leaf"
59
- title={title}
60
- description={description}
61
- iconName={iconName}
62
- iconColor={iconColor}
63
- label={label}
64
- labelIconName={labelIconName}
65
- labelIconColor={labelIconColor}
66
- // Thes props will be overridden by the TreeViewGroup
67
- level={-1}
68
- indexPath={[]}
69
- titlePath={[]}
70
- onClick={onClick}
71
- />
72
- );
26
+ return <TreeViewNode key={id} id={id} type="leaf" level={-1} indexPath={[]} titlePath={[]} {...rest} />;
73
27
  });
74
28
  };
75
29
 
76
- export type TreeViewProps = TreeViewContenxtProviderProps & {
30
+ export type TreeViewProps = TreeViewContextProviderProps & {
77
31
  label: string;
78
32
  data?: NodeData[];
33
+ variant?: TreeViewVariant;
79
34
  };
80
35
 
81
36
  const TreeView = ({ data, label, children, ...rest }: PropsWithChildren<TreeViewProps>) => {
@@ -1,21 +1,87 @@
1
+ import { PropsWithChildren } from 'react';
2
+ import { UseFloatingProps } from '@floating-ui/react-dom-interactions';
1
3
  import { TypeIconName } from '../Icon/Icon';
4
+ import { BadgeColorScheme, BadgeProps } from '../Badge/Badge';
2
5
 
3
6
  export type NodeType = 'branch' | 'leaf';
4
7
 
5
- export type NodeData = {
8
+ export type TreeViewVariant = 'navigation' | 'files';
9
+
10
+ export type HoverControlButton = {
11
+ iconName: TypeIconName;
12
+ label: string;
13
+ isDanger?: boolean;
14
+ onClick: (e: React.MouseEvent) => void;
15
+ };
16
+
17
+ type LeafIndicator =
18
+ | {
19
+ iconName?: TypeIconName;
20
+ iconColor?: string;
21
+ badgeLabel?: never;
22
+ badgeVariant?: never;
23
+ badgeColorScheme?: never;
24
+ badgeTooltip?: never;
25
+ }
26
+ | {
27
+ iconName?: never;
28
+ iconColor?: never;
29
+ badgeLabel?: string;
30
+ badgeVariant?: BadgeProps['variant'];
31
+ badgeColorScheme?: BadgeColorScheme;
32
+ badgeTooltip?: {
33
+ label: string;
34
+ placement?: UseFloatingProps['placement'];
35
+ };
36
+ };
37
+
38
+ type NodeBase = {
6
39
  id: string;
7
- type: NodeType;
8
40
  title: string;
9
41
  description?: string;
10
- iconName?: TypeIconName;
11
- iconColor?: string;
42
+ hasWarning?: boolean;
12
43
  label?: string;
13
44
  labelIconName?: TypeIconName;
14
45
  labelIconColor?: string;
15
- children?: NodeData[];
16
46
  onClick?: VoidFunction;
47
+ hoverActions?: [HoverControlButton] | [HoverControlButton, HoverControlButton];
48
+ children?: NodeData[];
17
49
  };
18
50
 
51
+ type LeafNodeData = NodeBase & { type: 'leaf' } & LeafIndicator;
52
+
53
+ type BranchNodeData = NodeBase & {
54
+ type: 'branch';
55
+ iconName?: TypeIconName;
56
+ iconColor?: string;
57
+ branchIconTooltip?: {
58
+ label: string;
59
+ placement?: UseFloatingProps['placement'];
60
+ };
61
+ };
62
+
63
+ export type NodeData = LeafNodeData | BranchNodeData;
64
+ export type { LeafNodeData, BranchNodeData };
65
+
66
+ type AllNodeProps = NodeBase & {
67
+ type: NodeType;
68
+ iconName?: TypeIconName;
69
+ iconColor?: string;
70
+ badgeLabel?: string;
71
+ badgeVariant?: BadgeProps['variant'];
72
+ badgeColorScheme?: BadgeColorScheme;
73
+ badgeTooltip?: {
74
+ label: string;
75
+ placement?: UseFloatingProps['placement'];
76
+ };
77
+ branchIconTooltip?: {
78
+ label: string;
79
+ placement?: UseFloatingProps['placement'];
80
+ };
81
+ };
82
+
83
+ export type TreeViewNodeProps = PropsWithChildren<Omit<AllNodeProps, 'children'> & NodePath>;
84
+
19
85
  export type NodePath = {
20
86
  level: number;
21
87
  indexPath: number[];
@@ -32,6 +98,7 @@ export type NodeState = {
32
98
  };
33
99
 
34
100
  export type TreeViewState = {
101
+ variant?: TreeViewVariant;
35
102
  selectedId?: string;
36
103
  expandedIds: Set<string>;
37
104
  disabledIds: Set<string>;
@@ -1,12 +1,15 @@
1
- import { memo, PropsWithChildren, useCallback, useMemo } from 'react';
1
+ import { memo, useCallback, useMemo, MouseEvent } from 'react';
2
2
  import { useMultiStyleConfig } from 'chakra-ui-2--react';
3
3
  import Icon from '../Icon/Icon';
4
+ import Badge from '../Badge/Badge';
4
5
  import Box from '../Box/Box';
5
6
  import Text from '../Text/Text';
6
7
  import Collapse from '../Collapse/Collapse';
8
+ import ControlButton from '../ControlButton/ControlButton';
9
+ import Tooltip from '../Tooltip/Tooltip';
7
10
  import TreeViewGroup from './TreeViewGroup';
8
11
  import { useTreeViewContext } from './TreeView.context';
9
- import { NodeData, NodePath } from './TreeView.types';
12
+ import { HoverControlButton, TreeViewNodeProps } from './TreeView.types';
10
13
 
11
14
  type TreeViewNodeContentProps = TreeViewNodeProps & {
12
15
  isBranch: boolean;
@@ -22,11 +25,18 @@ const TreeViewNodeContent = memo(
22
25
  id,
23
26
  title,
24
27
  description,
28
+ hasWarning,
25
29
  iconName,
26
30
  iconColor,
31
+ badgeLabel,
32
+ badgeVariant,
33
+ badgeColorScheme,
34
+ badgeTooltip,
27
35
  label,
28
36
  labelIconName,
29
37
  labelIconColor,
38
+ hoverActions,
39
+ branchIconTooltip,
30
40
  // NodePath
31
41
  level,
32
42
  indexPath,
@@ -40,6 +50,29 @@ const TreeViewNodeContent = memo(
40
50
  children,
41
51
  }: TreeViewNodeContentProps) => {
42
52
  const styles = useMultiStyleConfig('TreeView', {});
53
+ const { variant } = useTreeViewContext();
54
+
55
+ const isFilesVariant = variant === 'files';
56
+ const hasChildren = Boolean(children);
57
+ const isSelectable = !(isBranch && !hasChildren);
58
+ const isEmptyBranch = isBranch && !hasChildren;
59
+ const chevronIcon = isExpanded ? 'ChevronDown' : 'ChevronRight';
60
+ const primaryIconName = isFilesVariant ? 'FolderEmpty' : chevronIcon;
61
+
62
+ const showAncestorConnectors = isFilesVariant && level > 1;
63
+ const showOwnConnector = isFilesVariant && isBranch && isExpanded;
64
+
65
+ const hasHoverActions = hoverActions && hoverActions.length > 0;
66
+ const hasLabelContent = label || labelIconName || hasWarning;
67
+
68
+ const showIcon = !isBranch && iconName;
69
+ const showBadge = !isBranch && !iconName && badgeLabel;
70
+
71
+ const handleActionClick = useCallback((e: MouseEvent, action: HoverControlButton) => {
72
+ e.stopPropagation();
73
+ e.preventDefault();
74
+ action.onClick(e);
75
+ }, []);
43
76
 
44
77
  return (
45
78
  <Box
@@ -47,27 +80,59 @@ const TreeViewNodeContent = memo(
47
80
  id={id}
48
81
  role="treeitem"
49
82
  aria-level={level}
50
- aria-disabled={isDisabled}
83
+ aria-disabled={isDisabled || !isSelectable}
51
84
  aria-selected={isSelected}
52
- {...(isBranch ? { 'aria-expanded': isExpanded } : {})}
85
+ {...(isBranch ? { 'aria-expanded': hasChildren ? isExpanded : undefined } : {})}
86
+ sx={styles.listItem}
53
87
  >
54
88
  <Box
55
89
  as="button"
56
- tabIndex={isSelected ? 0 : -1}
57
- data-selected={isSelected ? '' : undefined}
58
- sx={{ '--level': `${level - 1}`, ...styles.button }}
90
+ tabIndex={!isSelectable || !isSelected ? -1 : 0}
91
+ data-selected={isSelectable && isSelected ? '' : undefined}
92
+ className={`${showAncestorConnectors ? 'show-ancestor-connectors' : ''}`}
93
+ sx={{
94
+ '--level': `${level - 1}`,
95
+ '--connector-color': 'var(--chakra-colors-border-regular)',
96
+ cursor: isEmptyBranch ? 'default' : 'pointer',
97
+ ...styles.button,
98
+ }}
59
99
  onClick={onClick}
60
100
  >
61
101
  <Box sx={{ ...styles.buttonContent }}>
62
- {isBranch && <Icon size="16" name={isExpanded ? 'ChevronDown' : 'ChevronRight'} sx={styles.icon} />}
102
+ {isBranch && (
103
+ <Box sx={styles.iconWrapper} className={showOwnConnector ? 'show-connector' : ''}>
104
+ {isFilesVariant && isEmptyBranch ? (
105
+ <Tooltip
106
+ label={branchIconTooltip?.label || 'Empty branch'}
107
+ placement={branchIconTooltip?.placement || 'top'}
108
+ >
109
+ <Icon size="16" name={primaryIconName} sx={styles.icon} />
110
+ </Tooltip>
111
+ ) : (
112
+ <Icon size="16" name={primaryIconName} sx={styles.icon} />
113
+ )}
114
+ </Box>
115
+ )}
63
116
  <Box sx={styles.borderBox}>
64
- {iconName && (
117
+ {showIcon && (
65
118
  <Icon
66
119
  size="16"
67
120
  name={iconName}
68
- sx={{ ...styles.icon, ...{ color: iconColor || (isSelected ? 'icon/primary' : 'icon/secondary') } }}
121
+ sx={{
122
+ ...styles.icon,
123
+ color: iconColor || (isSelected ? 'icon/primary' : 'icon/secondary'),
124
+ }}
69
125
  />
70
126
  )}
127
+
128
+ {showBadge && (
129
+ <Tooltip shouldWrapChildren label={badgeTooltip?.label} placement={badgeTooltip?.placement || 'top'}>
130
+ <Badge variant={badgeVariant} colorScheme={badgeColorScheme} size="xxs" sx={styles.leafBadge}>
131
+ {badgeLabel}
132
+ </Badge>
133
+ </Tooltip>
134
+ )}
135
+
71
136
  <Box sx={styles.textBlock}>
72
137
  <Text fontWeight={isExpanded ? 'semibold' : 'normal'} overflowWrap="break-word" wordBreak="break-word">
73
138
  {title}
@@ -83,25 +148,60 @@ const TreeViewNodeContent = memo(
83
148
  </Text>
84
149
  )}
85
150
  </Box>
86
- {(label || labelIconName) && (
151
+
152
+ {(hasLabelContent || hasHoverActions) && (
87
153
  <Box sx={styles.suffixBlock} color={isSelected || isExpanded ? 'text/primary' : 'text/secondary'}>
88
- {labelIconName && <Icon name={labelIconName} size="16" color={labelIconColor} />}
89
- {label && (
90
- <Text textStyle="body/md/regular" textAlign="right">
91
- {label}
92
- </Text>
154
+ {hasHoverActions && (
155
+ <Box sx={styles.hoverActionsWrapper} className="hover-actions">
156
+ {hoverActions.map((action, index) => (
157
+ <ControlButton
158
+ /* eslint-disable-next-line react/no-array-index-key */
159
+ key={index}
160
+ aria-label={action.label}
161
+ iconName={action.iconName}
162
+ isDanger={action.isDanger}
163
+ onClick={(e: MouseEvent) => handleActionClick(e, action)}
164
+ size="xs"
165
+ />
166
+ ))}
167
+ </Box>
168
+ )}
169
+ {hasLabelContent && (
170
+ <Box
171
+ display="flex"
172
+ alignItems="center"
173
+ gap="8"
174
+ pt="8px"
175
+ className={hasHoverActions ? 'hide-on-hover' : undefined}
176
+ >
177
+ {hasWarning && <Icon name="WarningYellow" size="16" />}
178
+ {labelIconName && <Icon name={labelIconName} size="16" color={labelIconColor} />}
179
+ {label && (
180
+ <Text textStyle="body/md/regular" textAlign="right">
181
+ {label}
182
+ </Text>
183
+ )}
184
+ </Box>
93
185
  )}
94
186
  </Box>
95
187
  )}
96
188
  </Box>
97
- {isSelected && <Box sx={styles.selectionIndicator} />}
189
+ {!isFilesVariant && isSelected && <Box sx={styles.selectionIndicator} />}
98
190
  </Box>
99
191
  </Box>
100
192
  {isBranch && children && (
101
193
  <Collapse in={isExpanded} unmountOnExit>
102
- <TreeViewGroup role="group" level={level + 1} indexPath={indexPath} titlePath={titlePath}>
103
- {children}
104
- </TreeViewGroup>
194
+ <Box
195
+ className={showOwnConnector ? 'show-connector' : undefined}
196
+ sx={{
197
+ '--level': `${level - 1}`,
198
+ ...styles.collapseContent,
199
+ }}
200
+ >
201
+ <TreeViewGroup role="group" level={level + 1} indexPath={indexPath} titlePath={titlePath}>
202
+ {children}
203
+ </TreeViewGroup>
204
+ </Box>
105
205
  </Collapse>
106
206
  )}
107
207
  </Box>
@@ -109,19 +209,24 @@ const TreeViewNodeContent = memo(
109
209
  },
110
210
  );
111
211
 
112
- export type TreeViewNodeProps = PropsWithChildren<Omit<NodeData, 'children'> & NodePath>;
113
-
114
212
  const TreeViewNode = ({
115
213
  // NodeData
116
214
  id,
117
215
  type,
118
216
  title,
119
217
  description,
218
+ hasWarning,
120
219
  iconName,
121
220
  iconColor,
221
+ badgeLabel,
222
+ badgeVariant,
223
+ badgeColorScheme,
224
+ badgeTooltip,
122
225
  label,
123
226
  labelIconName,
124
227
  labelIconColor,
228
+ hoverActions,
229
+ branchIconTooltip,
125
230
  onClick,
126
231
  // NodePath
127
232
  level,
@@ -133,12 +238,14 @@ const TreeViewNode = ({
133
238
  const { selectedId, disabledIds, expandedIds, onSelectionChange, onExpandedChange } = useTreeViewContext();
134
239
 
135
240
  const isBranch = type === 'branch';
241
+ const hasChildren = Boolean(children);
242
+ const isSelectable = !(isBranch && !hasChildren);
136
243
  const isDisabled = useMemo(() => disabledIds.has(id), [id, disabledIds]);
137
244
  const isExpanded = useMemo(() => (isBranch ? expandedIds.has(id) : undefined), [id, isBranch, expandedIds]);
138
- const isSelected = useMemo(() => id === selectedId, [id, selectedId]);
245
+ const isSelected = useMemo(() => (isSelectable ? id === selectedId : false), [id, selectedId, isSelectable]);
139
246
 
140
247
  const handleClick = useCallback(() => {
141
- if (isDisabled) {
248
+ if (isDisabled || !isSelectable) {
142
249
  return;
143
250
  }
144
251
 
@@ -158,7 +265,7 @@ const TreeViewNode = ({
158
265
  });
159
266
  }
160
267
 
161
- if (isBranch) {
268
+ if (isBranch && hasChildren) {
162
269
  onExpandedChange({
163
270
  node: {
164
271
  id,
@@ -198,11 +305,18 @@ const TreeViewNode = ({
198
305
  type={type}
199
306
  title={title}
200
307
  description={description}
308
+ hasWarning={hasWarning}
201
309
  iconName={iconName}
202
310
  iconColor={iconColor}
311
+ badgeLabel={badgeLabel}
312
+ badgeVariant={badgeVariant}
313
+ badgeColorScheme={badgeColorScheme}
314
+ badgeTooltip={badgeTooltip}
203
315
  label={label}
204
316
  labelIconName={labelIconName}
205
317
  labelIconColor={labelIconColor}
318
+ hoverActions={hoverActions}
319
+ branchIconTooltip={branchIconTooltip}
206
320
  level={level}
207
321
  indexPath={indexPath}
208
322
  titlePath={titlePath}