Skip to content

Commit 823b0bb

Browse files
alpavlovearturbien
authored andcommitted
feat: add Tree component
1 parent 44d9d75 commit 823b0bb

File tree

6 files changed

+682
-5
lines changed

6 files changed

+682
-5
lines changed

src/Counter/Digit.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ Digit.defaultProps = {
187187

188188
Digit.propTypes = {
189189
pixelSize: propTypes.number,
190-
digit: propTypes.oneOfType(propTypes.number, propTypes.string)
190+
digit: propTypes.oneOfType([propTypes.number, propTypes.string])
191191
};
192192

193193
export default Digit;

src/SwitchBase/SwitchBase.js

+1-4
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,7 @@ export const StyledLabel = styled.label`
2020
align-items: center;
2121
position: relative;
2222
margin: 8px 0;
23-
cursor: pointer;
24-
-webkit-user-select: none;
25-
-moz-user-select: none;
26-
-ms-user-select: none;
23+
cursor: ${({ isDisabled }) => (!isDisabled ? 'pointer' : 'auto')};
2724
user-select: none;
2825
font-size: 1rem;
2926
color: ${({ theme }) => theme.materialText};

src/Tree/Tree.js

+334
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
import React from 'react';
2+
import propTypes from 'prop-types';
3+
import styled, { css } from 'styled-components';
4+
5+
import { StyledLabel, LabelText } from '../SwitchBase/SwitchBase';
6+
import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled';
7+
8+
const Text = styled(LabelText)`
9+
white-space: nowrap;
10+
`;
11+
12+
const focusedElementStyles = css`
13+
:focus {
14+
outline: none;
15+
}
16+
17+
&:not([disabled]) {
18+
cursor: pointer;
19+
20+
:focus {
21+
${Text} {
22+
background: ${({ theme }) => theme.hoverBackground};
23+
color: ${({ theme }) => theme.materialTextInvert};
24+
outline: 2px dotted ${({ theme }) => theme.focusSecondary};
25+
}
26+
}
27+
}
28+
`;
29+
30+
const TreeView = styled.ul`
31+
position: relative;
32+
isolation: isolate;
33+
34+
${({ isRootLevel }) =>
35+
isRootLevel &&
36+
css`
37+
&:before {
38+
content: '';
39+
position: absolute;
40+
top: 20px;
41+
bottom: 0;
42+
left: 5.5px;
43+
width: 1px;
44+
border-left: 2px dashed ${({ theme }) => theme.borderDark};
45+
}
46+
`}
47+
48+
ul {
49+
padding-left: 19.5px;
50+
}
51+
52+
li {
53+
position: relative;
54+
55+
&:before {
56+
content: '';
57+
position: absolute;
58+
top: 17.5px;
59+
left: 5.5px;
60+
width: 22px;
61+
border-top: 2px dashed ${({ theme }) => theme.borderDark};
62+
font-size: 12px;
63+
}
64+
}
65+
`;
66+
67+
const TreeItem = styled.li`
68+
position: relative;
69+
padding-left: ${({ hasItems }) => (!hasItems ? '13px' : '0')};
70+
71+
${({ isRootLevel }) =>
72+
!isRootLevel
73+
? css`
74+
&:last-child {
75+
&:after {
76+
content: '';
77+
position: absolute;
78+
z-index: 1;
79+
top: 19.5px;
80+
bottom: 0;
81+
left: 1.5px;
82+
width: 10px;
83+
background: ${({ theme }) => theme.material};
84+
}
85+
}
86+
`
87+
: css`
88+
&:last-child {
89+
&:after {
90+
content: '';
91+
position: absolute;
92+
top: 19.5px;
93+
left: 1px;
94+
bottom: 0;
95+
width: 10px;
96+
background: ${({ theme }) => theme.material};
97+
}
98+
}
99+
`}
100+
101+
& > details > ul {
102+
&:after {
103+
content: '';
104+
position: absolute;
105+
top: -18px;
106+
bottom: 0;
107+
left: 25px;
108+
border-left: 2px dashed ${({ theme }) => theme.borderDark};
109+
}
110+
}
111+
`;
112+
113+
const Details = styled.details`
114+
position: relative;
115+
z-index: 2;
116+
117+
&[open] > summary:before {
118+
content: '-';
119+
}
120+
`;
121+
122+
const Summary = styled.summary`
123+
position: relative;
124+
z-index: 1;
125+
display: inline-flex;
126+
align-items: center;
127+
color: ${({ theme }) => theme.materialText};
128+
user-select: none;
129+
padding-left: 18px;
130+
${focusedElementStyles};
131+
132+
&::-webkit-details-marker {
133+
display: none;
134+
}
135+
136+
&:before {
137+
content: '+';
138+
position: absolute;
139+
left: 0;
140+
display: block;
141+
width: 8px;
142+
height: 9px;
143+
border: 2px solid #808080;
144+
padding-left: 1px;
145+
background-color: #fff;
146+
line-height: 8px;
147+
text-align: center;
148+
}
149+
`;
150+
151+
const TitleWithIcon = styled(StyledLabel)`
152+
position: relative;
153+
z-index: 1;
154+
background: none;
155+
border: 0;
156+
font-family: inherit;
157+
padding-top: 8px;
158+
padding-bottom: 8px;
159+
margin: 0;
160+
${focusedElementStyles};
161+
`;
162+
163+
const Icon = styled.span`
164+
display: flex;
165+
align-items: center;
166+
justify-content: center;
167+
width: 16px;
168+
height: 16px;
169+
margin-right: 6px;
170+
`;
171+
172+
function toggleItem(state, id) {
173+
return state.includes(id)
174+
? state.filter(item => item !== id)
175+
: [...state, id];
176+
}
177+
178+
const Tree = React.forwardRef(function Tree(
179+
{
180+
disabled,
181+
className,
182+
style,
183+
tree,
184+
defaultSelected,
185+
defaultExpanded,
186+
selected,
187+
expanded,
188+
onNodeSelect,
189+
onNodeToggle
190+
},
191+
ref
192+
) {
193+
const [expandedInternal, setExpandedInternal] = useControlledOrUncontrolled({
194+
value: expanded,
195+
defaultValue: defaultExpanded
196+
});
197+
198+
const [selectedInternal, setSelectedInternal] = useControlledOrUncontrolled({
199+
value: selected,
200+
defaultValue: defaultSelected
201+
});
202+
203+
function toggleMenu(event, id) {
204+
if (onNodeToggle) {
205+
const newState = toggleItem(expandedInternal, id);
206+
onNodeToggle(event, newState);
207+
}
208+
209+
setExpandedInternal(previouslyExpandedIds =>
210+
toggleItem(previouslyExpandedIds, id)
211+
);
212+
}
213+
214+
function select(event, id) {
215+
setSelectedInternal(id);
216+
217+
if (onNodeSelect) {
218+
onNodeSelect(event, id);
219+
}
220+
}
221+
222+
function handleLeafClick(event, id) {
223+
event.preventDefault();
224+
select(event, id);
225+
}
226+
227+
function handleSummaryClick(event, id) {
228+
event.preventDefault();
229+
select(event, id);
230+
toggleMenu(event, id);
231+
}
232+
233+
function renderTree(items, level = 0) {
234+
const isRootLevel = level === 0;
235+
236+
return (
237+
<TreeView
238+
className={isRootLevel ? className : undefined}
239+
style={isRootLevel ? style : undefined}
240+
ref={isRootLevel ? ref : undefined}
241+
role={isRootLevel ? 'tree' : 'group'}
242+
isRootLevel={isRootLevel}
243+
>
244+
{items.map(item => {
245+
const hasItems = item.items && item.items.length > 0;
246+
const isMenuShown = expandedInternal.includes(item.id);
247+
const isNodeDisabled = disabled || item.disabled;
248+
const onClickSummary = !isNodeDisabled
249+
? event => handleSummaryClick(event, item.id)
250+
: undefined;
251+
const onClickLeaf = !isNodeDisabled
252+
? event => handleLeafClick(event, item.id)
253+
: undefined;
254+
const isSelected = selectedInternal === item.id;
255+
const icon = <Icon aria-hidden>{item.icon}</Icon>;
256+
257+
return (
258+
<TreeItem
259+
key={item.label}
260+
isRootLevel={isRootLevel}
261+
role='treeitem'
262+
aria-expanded={isMenuShown}
263+
aria-selected={isSelected}
264+
hasItems={hasItems}
265+
>
266+
{!hasItems ? (
267+
<TitleWithIcon
268+
as='button'
269+
isDisabled={isNodeDisabled}
270+
onClick={onClickLeaf}
271+
>
272+
{icon}
273+
<Text>{item.label}</Text>
274+
</TitleWithIcon>
275+
) : (
276+
<Details open={isMenuShown}>
277+
<Summary onClick={onClickSummary} disabled={isNodeDisabled}>
278+
<TitleWithIcon isDisabled={isNodeDisabled}>
279+
{icon}
280+
<Text>{item.label}</Text>
281+
</TitleWithIcon>
282+
</Summary>
283+
284+
{isMenuShown && renderTree(item.items, level + 1)}
285+
</Details>
286+
)}
287+
</TreeItem>
288+
);
289+
})}
290+
</TreeView>
291+
);
292+
}
293+
294+
return renderTree(tree);
295+
});
296+
297+
Tree.defaultProps = {
298+
disabled: false,
299+
style: {},
300+
className: '',
301+
tree: [],
302+
defaultSelected: undefined,
303+
defaultExpanded: [],
304+
selected: undefined,
305+
expanded: undefined,
306+
onNodeToggle: undefined,
307+
onNodeSelect: undefined
308+
};
309+
310+
const idType = propTypes.oneOfType([propTypes.string, propTypes.number]);
311+
312+
const treeDataShape = {
313+
id: idType.isRequired,
314+
label: propTypes.string.isRequired,
315+
icon: propTypes.object.isRequired,
316+
disabled: propTypes.bool
317+
};
318+
319+
treeDataShape.items = propTypes.arrayOf(propTypes.shape(treeDataShape));
320+
321+
Tree.propTypes = {
322+
style: propTypes.object,
323+
className: propTypes.string,
324+
tree: propTypes.arrayOf(propTypes.shape(treeDataShape)),
325+
defaultSelected: idType,
326+
defaultExpanded: propTypes.arrayOf(idType),
327+
selected: idType,
328+
expanded: propTypes.arrayOf(idType),
329+
onNodeSelect: propTypes.func,
330+
onNodeToggle: propTypes.func,
331+
disabled: propTypes.bool
332+
};
333+
334+
export default Tree;

0 commit comments

Comments
 (0)