@ndla/ui 26.0.0 → 27.0.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.
Files changed (74) hide show
  1. package/es/Breadcrumb/Breadcrumb.js +3 -4
  2. package/es/MyNdla/Resource/Folder.js +32 -14
  3. package/es/Resource/BlockResource.js +43 -61
  4. package/es/Resource/ListResource.js +44 -23
  5. package/es/Resource/resourceComponents.js +64 -38
  6. package/es/TreeStructure/ComboboxButton.js +162 -0
  7. package/es/TreeStructure/FolderItem.js +98 -78
  8. package/es/TreeStructure/FolderItems.js +25 -14
  9. package/es/TreeStructure/FolderNameInput.js +40 -33
  10. package/es/TreeStructure/NavigationLink.js +18 -10
  11. package/es/TreeStructure/TreeStructure.js +92 -165
  12. package/es/TreeStructure/arrowNavigation.js +3 -3
  13. package/es/TreeStructure/helperFunctions.js +3 -0
  14. package/es/locale/messages-en.js +8 -2
  15. package/es/locale/messages-nb.js +8 -2
  16. package/es/locale/messages-nn.js +8 -2
  17. package/es/locale/messages-se.js +8 -2
  18. package/es/locale/messages-sma.js +8 -2
  19. package/lib/Breadcrumb/Breadcrumb.js +3 -5
  20. package/lib/MyNdla/Resource/Folder.d.ts +2 -1
  21. package/lib/MyNdla/Resource/Folder.js +37 -14
  22. package/lib/Resource/BlockResource.d.ts +2 -1
  23. package/lib/Resource/BlockResource.js +48 -61
  24. package/lib/Resource/ListResource.d.ts +2 -1
  25. package/lib/Resource/ListResource.js +49 -23
  26. package/lib/Resource/resourceComponents.d.ts +6 -1
  27. package/lib/Resource/resourceComponents.js +64 -37
  28. package/lib/TreeStructure/ComboboxButton.d.ts +28 -0
  29. package/lib/TreeStructure/ComboboxButton.js +176 -0
  30. package/lib/TreeStructure/FolderItem.d.ts +1 -1
  31. package/lib/TreeStructure/FolderItem.js +99 -77
  32. package/lib/TreeStructure/FolderItems.d.ts +4 -2
  33. package/lib/TreeStructure/FolderItems.js +26 -14
  34. package/lib/TreeStructure/FolderNameInput.d.ts +3 -1
  35. package/lib/TreeStructure/FolderNameInput.js +41 -32
  36. package/lib/TreeStructure/NavigationLink.d.ts +1 -1
  37. package/lib/TreeStructure/NavigationLink.js +18 -10
  38. package/lib/TreeStructure/TreeStructure.d.ts +2 -2
  39. package/lib/TreeStructure/TreeStructure.js +92 -165
  40. package/lib/TreeStructure/arrowNavigation.d.ts +2 -1
  41. package/lib/TreeStructure/arrowNavigation.js +3 -3
  42. package/lib/TreeStructure/helperFunctions.d.ts +2 -1
  43. package/lib/TreeStructure/helperFunctions.js +8 -2
  44. package/lib/TreeStructure/types.d.ts +6 -7
  45. package/lib/locale/messages-en.d.ts +6 -0
  46. package/lib/locale/messages-en.js +8 -2
  47. package/lib/locale/messages-nb.d.ts +6 -0
  48. package/lib/locale/messages-nb.js +8 -2
  49. package/lib/locale/messages-nn.d.ts +6 -0
  50. package/lib/locale/messages-nn.js +8 -2
  51. package/lib/locale/messages-se.d.ts +6 -0
  52. package/lib/locale/messages-se.js +8 -2
  53. package/lib/locale/messages-sma.d.ts +6 -0
  54. package/lib/locale/messages-sma.js +8 -2
  55. package/package.json +11 -11
  56. package/src/Breadcrumb/Breadcrumb.tsx +1 -2
  57. package/src/MyNdla/Resource/Folder.tsx +21 -5
  58. package/src/Resource/BlockResource.tsx +43 -33
  59. package/src/Resource/ListResource.tsx +37 -29
  60. package/src/Resource/resourceComponents.tsx +60 -26
  61. package/src/TreeStructure/ComboboxButton.tsx +189 -0
  62. package/src/TreeStructure/FolderItem.tsx +89 -70
  63. package/src/TreeStructure/FolderItems.tsx +36 -16
  64. package/src/TreeStructure/FolderNameInput.tsx +43 -18
  65. package/src/TreeStructure/NavigationLink.tsx +17 -10
  66. package/src/TreeStructure/TreeStructure.tsx +63 -139
  67. package/src/TreeStructure/arrowNavigation.ts +7 -6
  68. package/src/TreeStructure/helperFunctions.ts +5 -1
  69. package/src/TreeStructure/types.ts +6 -7
  70. package/src/locale/messages-en.ts +7 -0
  71. package/src/locale/messages-nb.ts +6 -0
  72. package/src/locale/messages-nn.ts +6 -0
  73. package/src/locale/messages-se.ts +7 -0
  74. package/src/locale/messages-sma.ts +7 -0
@@ -7,15 +7,23 @@
7
7
  */
8
8
 
9
9
  import styled from '@emotion/styled';
10
- import React from 'react';
11
- import SafeLink from '@ndla/safelink';
10
+ import React, { useRef } from 'react';
12
11
  import { colors, fonts, spacing } from '@ndla/core';
13
12
  import { MenuButton, MenuItemProps } from '@ndla/button';
14
13
  import Image from '../Image';
15
- import { CompressedTagList, ResourceImageProps, ResourceTitle, Row, TopicList } from './resourceComponents';
14
+ import {
15
+ CompressedTagList,
16
+ ResourceImageProps,
17
+ ResourceTitle,
18
+ Row,
19
+ TopicList,
20
+ ResourceTitleLink,
21
+ LoaderProps,
22
+ } from './resourceComponents';
16
23
  import ContentLoader from '../ContentLoader';
17
24
 
18
25
  interface BlockResourceProps {
26
+ id: string;
19
27
  link: string;
20
28
  tagLinkPrefix?: string;
21
29
  title: string;
@@ -27,7 +35,7 @@ interface BlockResourceProps {
27
35
  isLoading?: boolean;
28
36
  }
29
37
 
30
- const BlockElementWrapper = styled(SafeLink)`
38
+ const BlockElementWrapper = styled.div`
31
39
  display: flex;
32
40
  text-decoration: none;
33
41
  box-shadow: none;
@@ -37,6 +45,16 @@ const BlockElementWrapper = styled(SafeLink)`
37
45
  border: 1px solid ${colors.brand.light};
38
46
  border-radius: 2px;
39
47
  color: ${colors.brand.greyDark};
48
+ cursor: pointer;
49
+
50
+ &:hover {
51
+ box-shadow: 1px 1px 6px 2px rgba(9, 55, 101, 0.08);
52
+ transition-duration: 0.2s;
53
+ ${() => ResourceTitleLink} {
54
+ color: ${colors.brand.primary};
55
+ text-decoration: underline;
56
+ }
57
+ }
40
58
  `;
41
59
 
42
60
  const BlockDescription = styled.p`
@@ -48,7 +66,8 @@ const BlockDescription = styled.p`
48
66
  overflow: hidden;
49
67
  text-overflow: ellipsis;
50
68
  transition: height 0.2s ease-out;
51
- ${() => BlockElementWrapper}:hover &, ${() => BlockElementWrapper}:focus & {
69
+ ${() => BlockElementWrapper}:hover &, ${() => BlockElementWrapper}:focus & , ${() =>
70
+ BlockElementWrapper}:focus-within & {
52
71
  // Unfortunate css needed for multi-line text overflow ellipsis.
53
72
  height: 3.1em;
54
73
  -webkit-line-clamp: 2;
@@ -59,6 +78,7 @@ const BlockDescription = styled.p`
59
78
 
60
79
  const RightRow = styled(Row)`
61
80
  justify-content: flex-end;
81
+ margin-bottom: -${spacing.xxsmall};
62
82
  `;
63
83
 
64
84
  const BlockInfoWrapper = styled.div`
@@ -97,28 +117,7 @@ const BlockImage = ({ image, loading }: BlockImageProps) => {
97
117
  return <Image alt={image.alt} src={image.src} fallbackWidth={300} />;
98
118
  };
99
119
 
100
- interface BlockTitleProps {
101
- title: string;
102
- loading?: boolean;
103
- }
104
-
105
- const BlockTitle = ({ title, loading }: BlockTitleProps) => {
106
- if (loading) {
107
- return (
108
- <ContentLoader height={'18px'} width={'100%'} viewBox={null} preserveAspectRatio="none">
109
- <rect x="0" y="0" rx="3" ry="3" width="100%" height="18px" />
110
- </ContentLoader>
111
- );
112
- }
113
- return <ResourceTitle>{title}</ResourceTitle>;
114
- };
115
-
116
- interface BlockTopicListProps {
117
- topics: string[];
118
- loading?: boolean;
119
- }
120
-
121
- const BlockTopicList = ({ topics, loading }: BlockTopicListProps) => {
120
+ const TopicAndTitleLoader = ({ children, loading }: LoaderProps) => {
122
121
  if (loading) {
123
122
  return (
124
123
  <ContentLoader height={'18px'} width={'100%'} viewBox={null} preserveAspectRatio="none">
@@ -128,10 +127,11 @@ const BlockTopicList = ({ topics, loading }: BlockTopicListProps) => {
128
127
  );
129
128
  }
130
129
 
131
- return <TopicList topics={topics} />;
130
+ return <>{children}</>;
132
131
  };
133
132
 
134
133
  const BlockResource = ({
134
+ id,
135
135
  link,
136
136
  tagLinkPrefix,
137
137
  title,
@@ -142,16 +142,26 @@ const BlockResource = ({
142
142
  menuItems,
143
143
  isLoading,
144
144
  }: BlockResourceProps) => {
145
+ const linkRef = useRef<HTMLAnchorElement>(null);
146
+
147
+ const handleClick = () => {
148
+ if (linkRef.current) {
149
+ linkRef.current.click();
150
+ }
151
+ };
152
+
145
153
  return (
146
- <BlockElementWrapper to={link}>
154
+ <BlockElementWrapper onClick={handleClick} id={id}>
147
155
  <ImageWrapper>
148
156
  <BlockImage image={resourceImage} loading={isLoading} />
149
157
  </ImageWrapper>
150
158
  <BlockInfoWrapper>
151
- <div>
152
- <BlockTitle title={title} loading={isLoading} />
153
- </div>
154
- <BlockTopicList topics={topics} loading={isLoading} />
159
+ <TopicAndTitleLoader loading={isLoading}>
160
+ <ResourceTitleLink title={title} to={link} ref={linkRef}>
161
+ <ResourceTitle>{title}</ResourceTitle>
162
+ </ResourceTitleLink>
163
+ </TopicAndTitleLoader>
164
+ <TopicList topics={topics} />
155
165
  <BlockDescription>{description}</BlockDescription>
156
166
  <RightRow>
157
167
  {tags && <CompressedTagList tagLinkPrefix={tagLinkPrefix} tags={tags} />}
@@ -7,12 +7,18 @@
7
7
  */
8
8
 
9
9
  import styled from '@emotion/styled';
10
- import React from 'react';
11
- import SafeLink from '@ndla/safelink';
10
+ import React, { useRef } from 'react';
12
11
  import { fonts, spacing, colors, breakpoints, mq } from '@ndla/core';
13
12
  import { MenuButton, MenuItemProps } from '@ndla/button';
14
13
  import Image from '../Image';
15
- import { CompressedTagList, ResourceImageProps, ResourceTitle, TopicList } from './resourceComponents';
14
+ import {
15
+ CompressedTagList,
16
+ ResourceImageProps,
17
+ ResourceTitle,
18
+ ResourceTitleLink,
19
+ TopicList,
20
+ LoaderProps,
21
+ } from './resourceComponents';
16
22
  import ContentLoader from '../ContentLoader';
17
23
 
18
24
  const StyledResourceDescription = styled.p`
@@ -31,7 +37,7 @@ const StyledResourceDescription = styled.p`
31
37
  -webkit-box-orient: vertical;
32
38
  `;
33
39
 
34
- const ResourceWrapper = styled(SafeLink)`
40
+ const ResourceWrapper = styled.div`
35
41
  flex: 1;
36
42
  display: grid;
37
43
  grid-template-columns: auto 1fr auto;
@@ -47,38 +53,38 @@ const ResourceWrapper = styled(SafeLink)`
47
53
  'tags tags';
48
54
  }
49
55
 
50
- text-decoration: none;
51
- box-shadow: none;
56
+ cursor: pointer;
52
57
  padding: ${spacing.small};
53
58
  border: 1px solid ${colors.brand.neutral7};
54
59
  border-radius: 2px;
55
- color: ${colors.brand.greyDark};
56
60
  gap: 0 ${spacing.small};
57
61
 
58
62
  &:hover {
59
63
  box-shadow: 1px 1px 6px 2px rgba(9, 55, 101, 0.08);
60
64
  transition-duration: 0.2s;
61
- ${ResourceTitle} {
65
+ ${() => ResourceTitleLink} {
62
66
  color: ${colors.brand.primary};
63
67
  text-decoration: underline;
64
68
  }
65
- a {
66
- display: flex;
67
- align-items: center;
68
- }
69
69
  }
70
70
  `;
71
71
 
72
72
  const TagsandActionMenu = styled.div`
73
+ box-sizing: content-box;
74
+ padding: 2px;
73
75
  grid-area: tags;
74
76
  display: flex;
75
77
  align-items: center;
76
78
  width: 100%;
77
79
  overflow: hidden;
78
- gap: ${spacing.small};
79
80
  align-self: flex-start;
80
81
  justify-self: flex-end;
81
82
  justify-content: flex-end;
83
+
84
+ ${mq.range({ from: breakpoints.mobileWide })} {
85
+ margin-top: -${spacing.xsmall};
86
+ margin-right: -${spacing.xxsmall};
87
+ }
82
88
  `;
83
89
 
84
90
  const StyledImageWrapper = styled.div<StyledImageProps>`
@@ -100,7 +106,7 @@ const StyledImage = styled(Image)`
100
106
 
101
107
  const TopicAndTitleWrapper = styled.div`
102
108
  grid-area: topicAndTitle;
103
- margin-top: ${spacing.xxsmall};
109
+ margin-top: 2px;
104
110
  `;
105
111
 
106
112
  interface StyledImageProps {
@@ -108,6 +114,7 @@ interface StyledImageProps {
108
114
  }
109
115
 
110
116
  export interface ListResourceProps {
117
+ id: string;
111
118
  link: string;
112
119
  tagLinkPrefix?: string;
113
120
  title: string;
@@ -145,13 +152,7 @@ const ListResourceImage = ({ resourceImage, loading, type }: ListResourceImagePr
145
152
  );
146
153
  };
147
154
 
148
- interface TopicAndTitleProps {
149
- title: string;
150
- topics: string[];
151
- loading?: boolean;
152
- }
153
-
154
- const TopicAndTitle = ({ title, topics, loading }: TopicAndTitleProps) => {
155
+ const TopicAndTitleLoader = ({ loading, children }: LoaderProps) => {
155
156
  if (loading) {
156
157
  return (
157
158
  <ContentLoader height={'40px'} width={'100%'} viewBox={null} preserveAspectRatio="none">
@@ -161,12 +162,7 @@ const TopicAndTitle = ({ title, topics, loading }: TopicAndTitleProps) => {
161
162
  </ContentLoader>
162
163
  );
163
164
  }
164
- return (
165
- <>
166
- <ResourceTitle>{title}</ResourceTitle>
167
- <TopicList topics={topics} />
168
- </>
169
- );
165
+ return <>{children}</>;
170
166
  };
171
167
 
172
168
  interface ResourceDescriptionProps {
@@ -186,6 +182,7 @@ const ResourceDescription = ({ description, loading }: ResourceDescriptionProps)
186
182
  };
187
183
 
188
184
  const ListResource = ({
185
+ id,
189
186
  link,
190
187
  tagLinkPrefix,
191
188
  title,
@@ -198,14 +195,25 @@ const ListResource = ({
198
195
  }: ListResourceProps) => {
199
196
  const showDescription = description !== undefined;
200
197
  const imageType = showDescription ? 'normal' : 'compact';
198
+ const linkRef = useRef<HTMLAnchorElement>(null);
199
+ const handleClick = () => {
200
+ if (linkRef.current) {
201
+ linkRef.current.click();
202
+ }
203
+ };
201
204
 
202
205
  return (
203
- <ResourceWrapper to={link}>
206
+ <ResourceWrapper onClick={handleClick} id={id}>
204
207
  <StyledImageWrapper imageSize={imageType}>
205
208
  <ListResourceImage resourceImage={resourceImage} loading={isLoading} type={imageType} />
206
209
  </StyledImageWrapper>
207
210
  <TopicAndTitleWrapper>
208
- <TopicAndTitle topics={topics} title={title} loading={isLoading} />
211
+ <TopicAndTitleLoader loading={isLoading}>
212
+ <ResourceTitleLink to={link} ref={linkRef}>
213
+ <ResourceTitle>{title}</ResourceTitle>
214
+ </ResourceTitleLink>
215
+ <TopicList topics={topics} />
216
+ </TopicAndTitleLoader>
209
217
  </TopicAndTitleWrapper>
210
218
  {showDescription && <ResourceDescription description={description} loading={isLoading} />}
211
219
  <TagsandActionMenu>
@@ -7,19 +7,25 @@
7
7
  */
8
8
 
9
9
  import styled from '@emotion/styled';
10
- import { colors, fonts, spacing } from '@ndla/core';
11
- import React, { MouseEvent } from 'react';
12
-
10
+ import { colors, fonts, misc, spacing } from '@ndla/core';
11
+ import React, { MouseEvent, ReactNode } from 'react';
12
+ import { useTranslation } from 'react-i18next';
13
13
  import { MenuButton } from '@ndla/button';
14
14
  import SafeLink from '@ndla/safelink';
15
15
  import { useNavigate } from 'react-router-dom';
16
+ import { HashTag } from '@ndla/icons/common';
16
17
 
17
18
  export interface ResourceImageProps {
18
19
  alt: string;
19
20
  src: string;
20
21
  }
21
22
 
22
- export const ResourceTitle = styled.h3`
23
+ export const ResourceTitleLink = styled(SafeLink)`
24
+ box-shadow: none;
25
+ color: ${colors.brand.primary};
26
+ `;
27
+
28
+ export const ResourceTitle = styled.h2`
23
29
  min-width: 50px;
24
30
  margin: 0;
25
31
  flex: 1;
@@ -32,13 +38,14 @@ export const ResourceTitle = styled.h3`
32
38
  line-clamp: 1;
33
39
  -webkit-box-orient: vertical;
34
40
  grid-area: resourceTitle;
41
+ ${fonts.sizes('18px', '18px')};
35
42
  `;
36
43
 
37
44
  const StyledTagList = styled.ul`
38
45
  list-style: none;
39
46
  display: flex;
40
47
  margin: 0;
41
- padding: 0;
48
+ padding: 2px;
42
49
  gap: ${spacing.xsmall};
43
50
  overflow: hidden;
44
51
  `;
@@ -49,11 +56,10 @@ const StyledTagListElement = styled.li`
49
56
  `;
50
57
 
51
58
  const StyledSafeLink = styled(SafeLink)`
59
+ display: flex;
60
+ align-items: center;
52
61
  box-shadow: none;
53
62
  color: ${colors.brand.grey};
54
- ::before {
55
- content: '#';
56
- }
57
63
  &:hover {
58
64
  color: ${colors.brand.primary};
59
65
  }
@@ -68,16 +74,18 @@ const StyledTopicList = styled.ul`
68
74
  grid-area: topicList;
69
75
  `;
70
76
 
77
+ const StyledTopicDivider = styled.span`
78
+ margin: 0;
79
+ padding: 0 ${spacing.xxsmall};
80
+ `;
81
+
71
82
  const StyledTopicListElement = styled.li`
72
83
  ${fonts.sizes(12)};
73
84
  margin: 0;
74
85
  line-height: 1.5;
75
86
  padding: 0;
76
- `;
77
-
78
- const StyledTopicDivider = styled.span`
79
- margin: 0;
80
- padding: 0 ${spacing.xxsmall};
87
+ display: flex;
88
+ align-items: center;
81
89
  `;
82
90
 
83
91
  export const Row = styled.div`
@@ -86,27 +94,36 @@ export const Row = styled.div`
86
94
  gap: ${spacing.xsmall};
87
95
  `;
88
96
 
89
- const TagCounterWrapper = styled.p`
90
- color: ${colors.brand.primary};
97
+ const TagCounterWrapper = styled.span`
98
+ color: ${colors.brand.secondary};
91
99
  box-shadow: none;
92
100
  margin: 0;
93
101
  font-weight: ${fonts.weight.semibold};
94
- ${fonts.sizes(16)}
102
+ ${fonts.sizes('14px', '14px')};
103
+ padding: 5px;
95
104
  `;
96
105
 
97
106
  interface TagListProps {
98
107
  tags?: string[];
99
108
  tagLinkPrefix?: string;
100
109
  }
110
+
111
+ export interface LoaderProps {
112
+ loading?: boolean;
113
+ children?: ReactNode;
114
+ }
115
+
101
116
  export const TagList = ({ tags, tagLinkPrefix }: TagListProps) => {
117
+ const { t } = useTranslation();
102
118
  if (!tags) return null;
103
119
  return (
104
- <StyledTagList>
120
+ <StyledTagList aria-label={t('myNdla.tagList')}>
105
121
  {tags.map((tag, i) => (
106
122
  <StyledTagListElement key={`tag-${i}`}>
107
123
  <StyledSafeLink
108
124
  onClick={(e: MouseEvent<HTMLAnchorElement | HTMLElement>) => e.stopPropagation()}
109
125
  to={`${tagLinkPrefix ? tagLinkPrefix : ''}/${tag}`}>
126
+ <HashTag />
110
127
  {tag}
111
128
  </StyledSafeLink>
112
129
  </StyledTagListElement>
@@ -120,25 +137,39 @@ interface CompressedTagListProps {
120
137
  tagLinkPrefix?: string;
121
138
  }
122
139
 
140
+ const TagMenuButton = styled(MenuButton)`
141
+ &:hover,
142
+ &:active,
143
+ &:focus {
144
+ transition: ${misc.transition.default};
145
+ border-radius: 100%;
146
+ background-color: ${colors.brand.light};
147
+ }
148
+ `;
149
+
123
150
  export const CompressedTagList = ({ tags, tagLinkPrefix }: CompressedTagListProps) => {
124
151
  const navigate = useNavigate();
152
+ const { t } = useTranslation();
125
153
  const visibleTags = tags.slice(0, 3);
126
154
  const remainingTags = tags.slice(3, tags.length).map((tag) => {
127
155
  return {
128
- text: '#' + tag,
156
+ icon: <HashTag />,
157
+ text: tag,
129
158
  onClick: () => {
130
159
  navigate(`${tagLinkPrefix ? tagLinkPrefix : ''}/${tag}`);
131
160
  },
132
161
  };
133
162
  });
134
-
135
163
  return (
136
164
  <>
137
165
  <TagList tagLinkPrefix={tagLinkPrefix} tags={visibleTags} />
138
166
  {remainingTags.length > 0 && (
139
- <MenuButton hideMenuIcon={true} menuItems={remainingTags}>
167
+ <TagMenuButton
168
+ hideMenuIcon={true}
169
+ menuItems={remainingTags}
170
+ aria-label={t('myNdla.moreTags', { count: remainingTags.length })}>
140
171
  <TagCounterWrapper>{`+${remainingTags.length}`}</TagCounterWrapper>
141
- </MenuButton>
172
+ </TagMenuButton>
142
173
  )}
143
174
  </>
144
175
  );
@@ -149,14 +180,17 @@ interface TopicListProps {
149
180
  }
150
181
 
151
182
  export const TopicList = ({ topics }: TopicListProps) => {
183
+ const { t } = useTranslation();
152
184
  if (!topics) return null;
153
185
  return (
154
- <StyledTopicList>
186
+ <StyledTopicList aria-label={t('navigation.topics')}>
155
187
  {topics.map((topic, i) => (
156
- <StyledTopicListElement key={topic}>
157
- {topic}
158
- {i !== topics.length - 1 && <StyledTopicDivider>•</StyledTopicDivider>}
159
- </StyledTopicListElement>
188
+ <>
189
+ <StyledTopicListElement key={topic}>
190
+ {topic}
191
+ {i !== topics.length - 1 && <StyledTopicDivider aria-hidden="true">•</StyledTopicDivider>}
192
+ </StyledTopicListElement>
193
+ </>
160
194
  ))}
161
195
  </StyledTopicList>
162
196
  );
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Copyright (c) 2022-present, NDLA.
3
+ *
4
+ * This source code is licensed under the GPLv3 license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+
9
+ import React, { KeyboardEvent } from 'react';
10
+ import { useTranslation } from 'react-i18next';
11
+ import styled from '@emotion/styled';
12
+ import { useForwardedRef } from '@ndla/util';
13
+ import Tooltip from '@ndla/tooltip';
14
+ import { colors, spacing } from '@ndla/core';
15
+ import { IFolder } from '@ndla/types-learningpath-api';
16
+ import { Plus } from '@ndla/icons/action';
17
+ import { ChevronUp, ChevronDown } from '@ndla/icons/common';
18
+ import { forwardRef } from 'react';
19
+ import { ButtonV2 as Button, IconButtonV2 as IconButton } from '@ndla/button';
20
+ import { treestructureId } from './helperFunctions';
21
+ import { FolderType, TreeStructureType } from './types';
22
+ import { arrowNavigation } from './arrowNavigation';
23
+
24
+ interface StyledRowProps {
25
+ isOpen: boolean;
26
+ }
27
+
28
+ const StyledRow = styled.div<StyledRowProps>`
29
+ display: flex;
30
+ justify-content: space-between;
31
+ padding: ${spacing.xxsmall};
32
+ border-bottom: ${({ isOpen }) => isOpen && `1px solid ${colors.brand.tertiary}`};
33
+ `;
34
+ const StyledSelectedFolder = styled(Button)`
35
+ flex: 1;
36
+ justify-content: flex-start;
37
+ color: ${colors.black};
38
+ :hover,
39
+ :focus {
40
+ background: none;
41
+ box-shadow: none;
42
+ border-color: transparent;
43
+ }
44
+ :focus-visible {
45
+ outline: none;
46
+ }
47
+ `;
48
+
49
+ const StyledAddFolderButton = styled(Button)`
50
+ &,
51
+ &:disabled {
52
+ border-color: transparent;
53
+ }
54
+ `;
55
+
56
+ const StyledPlus = styled(Plus)`
57
+ height: 24px;
58
+ width: 24px;
59
+ `;
60
+
61
+ interface Props {
62
+ showTree: boolean;
63
+ type: TreeStructureType;
64
+ label?: string;
65
+ focusedFolder?: FolderType;
66
+ selectedFolder?: FolderType;
67
+ setSelectedFolder: (folder?: FolderType) => void;
68
+ onToggleTree: (open: boolean) => void;
69
+ flattenedFolders: FolderType[];
70
+ onOpenFolder: (id: string) => void;
71
+ onCloseFolder: (id: string) => void;
72
+ setFocusedFolder: (folder?: FolderType) => void;
73
+ onNewFolder?: (name: string, parentId: string) => Promise<IFolder>;
74
+ maxLevel: number;
75
+ setNewFolderParentId: (id?: string) => void;
76
+ }
77
+
78
+ const ComboboxButton = forwardRef<HTMLButtonElement, Props>(
79
+ (
80
+ {
81
+ showTree,
82
+ type,
83
+ label,
84
+ focusedFolder,
85
+ selectedFolder,
86
+ setSelectedFolder,
87
+ onToggleTree,
88
+ flattenedFolders,
89
+ setFocusedFolder,
90
+ onOpenFolder,
91
+ onCloseFolder,
92
+ onNewFolder,
93
+ maxLevel,
94
+ setNewFolderParentId,
95
+ },
96
+ ref,
97
+ ) => {
98
+ const { t } = useTranslation();
99
+ const innerRef = useForwardedRef(ref);
100
+
101
+ const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
102
+ if (e.key === 'Enter') {
103
+ if (showTree) {
104
+ setSelectedFolder(focusedFolder);
105
+ }
106
+ return;
107
+ }
108
+ if (e.key === 'Escape') {
109
+ onToggleTree(false);
110
+ return;
111
+ }
112
+ if (['ArrowUp', 'ArrowDown'].includes(e.key) && !showTree) {
113
+ onToggleTree(true);
114
+ return;
115
+ }
116
+ if (focusedFolder) {
117
+ arrowNavigation(e, focusedFolder.id, flattenedFolders, setFocusedFolder, onOpenFolder, onCloseFolder);
118
+ }
119
+ };
120
+
121
+ const canAddFolder = selectedFolder && selectedFolder?.breadcrumbs.length < (maxLevel || 1);
122
+ return (
123
+ <StyledRow isOpen={showTree}>
124
+ <StyledSelectedFolder
125
+ ref={innerRef}
126
+ tabIndex={0}
127
+ id={treestructureId(type, 'combobox')}
128
+ role="combobox"
129
+ aria-controls={treestructureId(type, 'popup')}
130
+ aria-haspopup="tree"
131
+ aria-expanded={showTree}
132
+ aria-labelledby={label ? treestructureId(type, 'label') : undefined}
133
+ aria-activedescendant={focusedFolder ? treestructureId(type, focusedFolder.id) : undefined}
134
+ variant="ghost"
135
+ colorTheme="light"
136
+ fontWeight="normal"
137
+ shape="sharp"
138
+ onKeyDown={onKeyDown}
139
+ onClick={() => {
140
+ onToggleTree(!showTree);
141
+ }}>
142
+ {selectedFolder?.name}
143
+ </StyledSelectedFolder>
144
+ {onNewFolder && showTree && (
145
+ <Tooltip
146
+ tooltip={
147
+ canAddFolder
148
+ ? t('myNdla.newFolderUnder', {
149
+ folderName: selectedFolder?.name,
150
+ })
151
+ : t('treeStructure.maxFoldersAlreadyAdded')
152
+ }>
153
+ <StyledAddFolderButton
154
+ variant="outline"
155
+ shape="pill"
156
+ disabled={!canAddFolder}
157
+ aria-label={
158
+ canAddFolder
159
+ ? t('myNdla.newFolderUnder', {
160
+ folderName: selectedFolder?.name,
161
+ })
162
+ : t('treeStructure.maxFoldersAlreadyAdded')
163
+ }
164
+ onClick={() => setNewFolderParentId(focusedFolder?.id)}>
165
+ <StyledPlus /> {t('myNdla.newFolder')}
166
+ </StyledAddFolderButton>
167
+ </Tooltip>
168
+ )}
169
+ <IconButton
170
+ aria-hidden
171
+ aria-label=""
172
+ tabIndex={-1}
173
+ variant="ghost"
174
+ colorTheme="greyLighter"
175
+ size="small"
176
+ onClick={() => {
177
+ if (!showTree) {
178
+ innerRef.current?.focus();
179
+ }
180
+ onToggleTree(!showTree);
181
+ }}>
182
+ {showTree ? <ChevronUp /> : <ChevronDown />}
183
+ </IconButton>
184
+ </StyledRow>
185
+ );
186
+ },
187
+ );
188
+
189
+ export default ComboboxButton;