@ndla/ui 14.0.0 → 15.1.1

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 (193) hide show
  1. package/es/Article/Article.js +22 -3
  2. package/es/Article/ArticleFavoritesButton.js +38 -0
  3. package/es/Article/index.js +2 -1
  4. package/es/Breadcrumb/ActionBreadcrumb.js +57 -0
  5. package/es/Breadcrumb/index.js +1 -0
  6. package/es/InfoBlock/InfoBlock.js +55 -0
  7. package/es/InfoBlock/index.js +1 -0
  8. package/es/MyNdla/Navigation/VerticalNavigation.js +51 -0
  9. package/es/MyNdla/Navigation/index.js +2 -0
  10. package/es/MyNdla/Resource/Folder.js +86 -0
  11. package/es/MyNdla/Resource/FolderInput.js +96 -0
  12. package/{lib/MyNdla/ResourceDash/ResourcesView.d.ts → es/MyNdla/Resource/index.js} +3 -3
  13. package/es/MyNdla/index.js +4 -4
  14. package/es/Resource/BlockResource.js +73 -0
  15. package/es/Resource/ListResource.js +66 -0
  16. package/es/Resource/index.js +10 -0
  17. package/es/Resource/resourceComponents.js +97 -0
  18. package/es/ResourceGroup/ResourceGroup.js +7 -5
  19. package/es/ResourceGroup/ResourceItem.js +25 -24
  20. package/es/ResourceGroup/ResourceList.js +18 -6
  21. package/es/SnackBar/SnackBar.js +117 -0
  22. package/es/SnackBar/index.js +9 -0
  23. package/es/TagSelector/SuggestionInput.js +240 -0
  24. package/es/TagSelector/Suggestions.js +93 -0
  25. package/es/TagSelector/TagSelector.js +137 -0
  26. package/es/TagSelector/index.js +9 -0
  27. package/es/TreeStructure/FolderItem.js +130 -0
  28. package/es/TreeStructure/FolderItems.js +123 -0
  29. package/es/TreeStructure/FolderNameInput.js +112 -0
  30. package/es/TreeStructure/TreeStructure.js +254 -0
  31. package/es/TreeStructure/TreeStructure.types.js +0 -0
  32. package/es/TreeStructure/TreeStructureWrapper.js +13 -0
  33. package/es/TreeStructure/helperFunctions.js +92 -0
  34. package/es/TreeStructure/index.js +9 -0
  35. package/es/TreeStructure/keyboardNavigation/keyboardNavigation.js +182 -0
  36. package/es/TreeStructure/keyboardNavigation/keyboardNavigation.types.js +0 -0
  37. package/es/all.css +72 -0
  38. package/es/index.js +8 -3
  39. package/es/locale/messages-en.js +62 -4
  40. package/es/locale/messages-nb.js +61 -3
  41. package/es/locale/messages-nn.js +61 -3
  42. package/es/locale/messages-se.js +61 -3
  43. package/es/locale/messages-sma.js +61 -3
  44. package/lib/Article/Article.d.ts +3 -1
  45. package/lib/Article/Article.js +43 -23
  46. package/lib/Article/ArticleFavoritesButton.d.ts +15 -0
  47. package/lib/Article/ArticleFavoritesButton.js +56 -0
  48. package/lib/Article/index.d.ts +2 -1
  49. package/lib/Article/index.js +8 -0
  50. package/lib/Breadcrumb/ActionBreadcrumb.d.ts +16 -0
  51. package/lib/Breadcrumb/ActionBreadcrumb.js +72 -0
  52. package/lib/Breadcrumb/index.d.ts +1 -0
  53. package/lib/Breadcrumb/index.js +8 -0
  54. package/lib/InfoBlock/InfoBlock.d.ts +8 -0
  55. package/lib/InfoBlock/InfoBlock.js +58 -0
  56. package/lib/InfoBlock/index.d.ts +1 -0
  57. package/lib/InfoBlock/index.js +13 -0
  58. package/lib/MyNdla/Navigation/VerticalNavigation.d.ts +10 -0
  59. package/lib/MyNdla/Navigation/VerticalNavigation.js +61 -0
  60. package/lib/MyNdla/Navigation/index.d.ts +2 -0
  61. package/lib/MyNdla/Navigation/index.js +15 -0
  62. package/lib/MyNdla/Resource/Folder.d.ts +20 -0
  63. package/lib/MyNdla/Resource/Folder.js +100 -0
  64. package/lib/MyNdla/Resource/FolderInput.d.ts +15 -0
  65. package/lib/MyNdla/Resource/FolderInput.js +116 -0
  66. package/lib/MyNdla/Resource/index.d.ts +10 -0
  67. package/lib/MyNdla/Resource/index.js +23 -0
  68. package/lib/MyNdla/index.d.ts +4 -4
  69. package/lib/MyNdla/index.js +13 -7
  70. package/lib/Resource/BlockResource.d.ts +20 -0
  71. package/lib/Resource/BlockResource.js +84 -0
  72. package/lib/Resource/ListResource.d.ts +20 -0
  73. package/lib/Resource/ListResource.js +78 -0
  74. package/lib/Resource/index.d.ts +11 -0
  75. package/lib/Resource/index.js +29 -0
  76. package/lib/Resource/resourceComponents.d.ts +24 -0
  77. package/lib/Resource/resourceComponents.js +106 -0
  78. package/lib/ResourceGroup/ResourceGroup.d.ts +2 -1
  79. package/lib/ResourceGroup/ResourceGroup.js +7 -5
  80. package/lib/ResourceGroup/ResourceItem.d.ts +5 -1
  81. package/lib/ResourceGroup/ResourceItem.js +26 -24
  82. package/lib/ResourceGroup/ResourceList.d.ts +3 -1
  83. package/lib/ResourceGroup/ResourceList.js +18 -6
  84. package/lib/SnackBar/SnackBar.d.ts +23 -0
  85. package/lib/SnackBar/SnackBar.js +127 -0
  86. package/lib/SnackBar/index.d.ts +10 -0
  87. package/lib/SnackBar/index.js +15 -0
  88. package/lib/TagSelector/SuggestionInput.d.ts +19 -0
  89. package/lib/TagSelector/SuggestionInput.js +255 -0
  90. package/lib/TagSelector/Suggestions.d.ts +12 -0
  91. package/lib/TagSelector/Suggestions.js +96 -0
  92. package/lib/TagSelector/TagSelector.d.ts +16 -0
  93. package/lib/TagSelector/TagSelector.js +150 -0
  94. package/lib/TagSelector/index.d.ts +10 -0
  95. package/lib/TagSelector/index.js +19 -0
  96. package/lib/TreeStructure/FolderItem.d.ts +27 -0
  97. package/lib/TreeStructure/FolderItem.js +140 -0
  98. package/lib/TreeStructure/FolderItems.d.ts +11 -0
  99. package/lib/TreeStructure/FolderItems.js +130 -0
  100. package/lib/TreeStructure/FolderNameInput.d.ts +15 -0
  101. package/lib/TreeStructure/FolderNameInput.js +125 -0
  102. package/lib/TreeStructure/TreeStructure.d.ts +12 -0
  103. package/lib/TreeStructure/TreeStructure.js +273 -0
  104. package/lib/TreeStructure/TreeStructure.types.d.ts +63 -0
  105. package/lib/TreeStructure/TreeStructure.types.js +1 -0
  106. package/lib/TreeStructure/TreeStructureWrapper.d.ts +12 -0
  107. package/lib/TreeStructure/TreeStructureWrapper.js +24 -0
  108. package/lib/TreeStructure/helperFunctions.d.ts +5 -0
  109. package/lib/TreeStructure/helperFunctions.js +103 -0
  110. package/lib/TreeStructure/index.d.ts +10 -0
  111. package/lib/TreeStructure/index.js +15 -0
  112. package/lib/TreeStructure/keyboardNavigation/keyboardNavigation.d.ts +11 -0
  113. package/lib/TreeStructure/keyboardNavigation/keyboardNavigation.js +186 -0
  114. package/lib/TreeStructure/keyboardNavigation/keyboardNavigation.types.d.ts +26 -0
  115. package/lib/TreeStructure/keyboardNavigation/keyboardNavigation.types.js +1 -0
  116. package/lib/User/apiTypes.d.ts +1 -1
  117. package/lib/User/index.d.ts +2 -2
  118. package/lib/all.css +72 -0
  119. package/lib/index.d.ts +13 -4
  120. package/lib/index.js +75 -9
  121. package/lib/locale/messages-en.d.ts +58 -0
  122. package/lib/locale/messages-en.js +62 -4
  123. package/lib/locale/messages-nb.d.ts +58 -0
  124. package/lib/locale/messages-nb.js +61 -3
  125. package/lib/locale/messages-nn.d.ts +58 -0
  126. package/lib/locale/messages-nn.js +61 -3
  127. package/lib/locale/messages-se.d.ts +58 -0
  128. package/lib/locale/messages-se.js +61 -3
  129. package/lib/locale/messages-sma.d.ts +58 -0
  130. package/lib/locale/messages-sma.js +61 -3
  131. package/lib/types.d.ts +1 -1
  132. package/package.json +12 -11
  133. package/src/Article/Article.tsx +31 -0
  134. package/src/Article/ArticleFavoritesButton.tsx +40 -0
  135. package/src/Article/index.ts +2 -0
  136. package/src/Breadcrumb/ActionBreadcrumb.tsx +68 -0
  137. package/src/Breadcrumb/index.ts +2 -0
  138. package/src/InfoBlock/InfoBlock.tsx +61 -0
  139. package/src/InfoBlock/index.ts +1 -0
  140. package/src/MyNdla/Navigation/VerticalNavigation.tsx +93 -0
  141. package/src/MyNdla/Navigation/index.ts +2 -0
  142. package/src/MyNdla/Resource/Folder.tsx +145 -0
  143. package/src/MyNdla/Resource/FolderInput.tsx +104 -0
  144. package/src/MyNdla/Resource/index.ts +11 -0
  145. package/src/MyNdla/index.ts +4 -5
  146. package/src/Resource/BlockResource.tsx +101 -0
  147. package/src/Resource/ListResource.tsx +111 -0
  148. package/src/Resource/index.ts +12 -0
  149. package/src/Resource/resourceComponents.tsx +143 -0
  150. package/src/ResourceGroup/ResourceGroup.tsx +3 -0
  151. package/src/ResourceGroup/ResourceItem.tsx +17 -0
  152. package/src/ResourceGroup/ResourceList.tsx +16 -3
  153. package/src/SnackBar/SnackBar.tsx +183 -0
  154. package/src/SnackBar/index.ts +13 -0
  155. package/src/TagSelector/SuggestionInput.tsx +230 -0
  156. package/src/TagSelector/Suggestions.tsx +125 -0
  157. package/src/TagSelector/TagSelector.tsx +111 -0
  158. package/src/TagSelector/index.ts +13 -0
  159. package/src/TreeStructure/FolderItem.tsx +160 -0
  160. package/src/TreeStructure/FolderItems.tsx +109 -0
  161. package/src/TreeStructure/FolderNameInput.tsx +109 -0
  162. package/src/TreeStructure/TreeStructure.tsx +184 -0
  163. package/src/TreeStructure/TreeStructure.types.ts +69 -0
  164. package/src/TreeStructure/TreeStructureWrapper.tsx +34 -0
  165. package/src/TreeStructure/helperFunctions.ts +52 -0
  166. package/src/TreeStructure/index.ts +11 -0
  167. package/src/TreeStructure/keyboardNavigation/keyboardNavigation.ts +161 -0
  168. package/src/TreeStructure/keyboardNavigation/keyboardNavigation.types.ts +28 -0
  169. package/src/User/apiTypes.ts +1 -1
  170. package/src/User/index.ts +2 -2
  171. package/src/all.scss +1 -0
  172. package/src/index.ts +14 -5
  173. package/src/locale/messages-en.ts +56 -3
  174. package/src/locale/messages-nb.ts +55 -2
  175. package/src/locale/messages-nn.ts +55 -2
  176. package/src/locale/messages-se.ts +55 -2
  177. package/src/locale/messages-sma.ts +55 -2
  178. package/src/types.ts +1 -1
  179. package/es/MyNdla/ResourceDash/Breadcrumbs.js +0 -22
  180. package/es/MyNdla/ResourceDash/ResourceElement.js +0 -27
  181. package/es/MyNdla/ResourceDash/ResourcesView.js +0 -43
  182. package/es/MyNdla/ResourceDash/index.js +0 -4
  183. package/lib/MyNdla/ResourceDash/Breadcrumbs.d.ts +0 -15
  184. package/lib/MyNdla/ResourceDash/Breadcrumbs.js +0 -35
  185. package/lib/MyNdla/ResourceDash/ResourceElement.d.ts +0 -18
  186. package/lib/MyNdla/ResourceDash/ResourceElement.js +0 -38
  187. package/lib/MyNdla/ResourceDash/ResourcesView.js +0 -57
  188. package/lib/MyNdla/ResourceDash/index.d.ts +0 -4
  189. package/lib/MyNdla/ResourceDash/index.js +0 -31
  190. package/src/MyNdla/ResourceDash/Breadcrumbs.tsx +0 -31
  191. package/src/MyNdla/ResourceDash/ResourceElement.tsx +0 -50
  192. package/src/MyNdla/ResourceDash/ResourcesView.tsx +0 -42
  193. package/src/MyNdla/ResourceDash/index.ts +0 -5
@@ -15,6 +15,7 @@ import SafeLink from '@ndla/safelink';
15
15
  import { Additional, Core, HumanMaleBoard } from '@ndla/icons/common';
16
16
  import { breakpoints, colors, fonts, mq, spacing } from '@ndla/core';
17
17
  import Tooltip from '@ndla/tooltip';
18
+ import { ArticleFavoritesButton } from '../Article';
18
19
  import { Resource } from '../types';
19
20
  import ContentTypeBadge from '../ContentTypeBadge';
20
21
  import * as contentTypes from '../model/ContentType';
@@ -208,6 +209,7 @@ const IconWrapper = styled.div`
208
209
  const TypeWrapper = styled.div`
209
210
  display: flex;
210
211
  align-items: center;
212
+ gap: ${spacing.xsmall};
211
213
  `;
212
214
 
213
215
  const ContentTypeName = styled.span`
@@ -219,15 +221,20 @@ const ContentTypeName = styled.span`
219
221
  `;
220
222
 
221
223
  type Props = {
224
+ id: string;
222
225
  showContentTypeDescription?: boolean;
223
226
  contentTypeName?: string;
224
227
  contentTypeDescription?: string;
225
228
  extraBottomMargin?: boolean;
226
229
  showAdditionalResources?: boolean;
227
230
  access?: 'teacher';
231
+ isFavorite?: boolean;
232
+ onToggleAddToFavorites: (id: string, add: boolean) => void;
233
+ hideAddToFavoriteButton?: boolean;
228
234
  };
229
235
 
230
236
  const ResourceItem = ({
237
+ id,
231
238
  contentTypeName,
232
239
  contentTypeDescription,
233
240
  name,
@@ -238,6 +245,9 @@ const ResourceItem = ({
238
245
  extraBottomMargin,
239
246
  showAdditionalResources,
240
247
  access,
248
+ onToggleAddToFavorites,
249
+ isFavorite,
250
+ hideAddToFavoriteButton,
241
251
  }: Props & Resource) => {
242
252
  const { t } = useTranslation();
243
253
  const hidden = additional ? !showAdditionalResources : false;
@@ -291,6 +301,13 @@ const ResourceItem = ({
291
301
  )}
292
302
  </>
293
303
  )}
304
+ {!hideAddToFavoriteButton && (
305
+ <ArticleFavoritesButton
306
+ isFavorite={isFavorite}
307
+ articleId={id}
308
+ onToggleAddToFavorites={() => onToggleAddToFavorites(id, true)}
309
+ />
310
+ )}
294
311
  </TypeWrapper>
295
312
  </ListElement>
296
313
  );
@@ -48,9 +48,19 @@ export type ResourceListProps = {
48
48
  contentType?: string;
49
49
  title?: string;
50
50
  showAdditionalResources?: boolean;
51
+ onToggleAddToFavorites: (id: string, add: boolean) => void;
52
+ hideAddToFavoriteButton?: boolean;
51
53
  };
52
54
 
53
- const ResourceList = ({ resources, onClick, contentType, title, showAdditionalResources }: ResourceListProps) => {
55
+ const ResourceList = ({
56
+ resources,
57
+ onClick,
58
+ onToggleAddToFavorites,
59
+ contentType,
60
+ title,
61
+ showAdditionalResources,
62
+ hideAddToFavoriteButton,
63
+ }: ResourceListProps) => {
54
64
  const { t } = useTranslation();
55
65
  const renderAdditionalResourceTrigger =
56
66
  !showAdditionalResources &&
@@ -60,11 +70,14 @@ const ResourceList = ({ resources, onClick, contentType, title, showAdditionalRe
60
70
  return (
61
71
  <div>
62
72
  <StyledResourceList showAdditionalResources={showAdditionalResources}>
63
- {resources.map((resource) => (
73
+ {resources.map(({ id, ...resource }) => (
64
74
  <ResourceItem
65
- key={resource.id}
75
+ id={id}
76
+ key={id}
66
77
  contentType={contentType}
67
78
  showAdditionalResources={showAdditionalResources}
79
+ hideAddToFavoriteButton={hideAddToFavoriteButton}
80
+ onToggleAddToFavorites={onToggleAddToFavorites}
68
81
  {...resource}
69
82
  contentTypeDescription={
70
83
  resource.additional ? t('resource.tooltipAdditionalTopic') : t('resource.tooltipCoreTopic')
@@ -0,0 +1,183 @@
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, { ReactElement, useRef, useEffect, useState } from 'react';
10
+ import styled from '@emotion/styled';
11
+ import Button, { IconButton } from '@ndla/button';
12
+ import { spacing, spacingUnit, shadows, misc, fonts, colors, mq, breakpoints } from '@ndla/core';
13
+ import { Cross } from '@ndla/icons/action';
14
+ import { useTranslation } from 'react-i18next';
15
+
16
+ const StyledActionButton = styled(Button)`
17
+ color: ${colors.white};
18
+ padding: ${spacing.xsmall} ${spacing.small};
19
+ box-shadow: none;
20
+ &:focus,
21
+ &:hover {
22
+ color: ${colors.brand.greyLightest};
23
+ background: ${colors.brand.greyDark};
24
+ &:after {
25
+ opacity: 0;
26
+ }
27
+ }
28
+ &:after {
29
+ content: '';
30
+ display: flex;
31
+ height: 1px;
32
+ width: 100%;
33
+ background: ${colors.white};
34
+ transform: translateY(-2px);
35
+ }
36
+ `;
37
+
38
+ const StyledIconButton = styled(IconButton)`
39
+ svg {
40
+ color: ${colors.brand.greyMedium};
41
+ }
42
+ &:hover,
43
+ &:focus {
44
+ background: ${colors.brand.greyDark};
45
+ svg {
46
+ color: ${colors.brand.greyLightest};
47
+ }
48
+ }
49
+ `;
50
+
51
+ const WrapperForButtons = styled.div`
52
+ display: flex;
53
+ ${mq.range({ from: breakpoints.tablet })} {
54
+ gap: ${spacing.xxsmall};
55
+ }
56
+ `;
57
+
58
+ interface StyledProps {
59
+ expired?: boolean;
60
+ }
61
+
62
+ const Wrapper = styled.div`
63
+ position: fixed;
64
+ z-index: 99999;
65
+ bottom: ${spacing.small};
66
+ left: ${spacing.small};
67
+ right: ${spacing.small};
68
+ display: flex;
69
+ justify-content: center;
70
+ `;
71
+
72
+ const StyledNotification = styled.div<StyledProps>`
73
+ max-width: 960px;
74
+ ${fonts.sizes(18, 1.25)};
75
+ background: ${colors.text.primary};
76
+ color: ${colors.white};
77
+ box-shadow: ${shadows.levitate1};
78
+ padding: ${spacing.small};
79
+ padding-right: ${spacing.xsmall};
80
+ gap: ${spacing.medium};
81
+ ${mq.range({ from: breakpoints.tablet })} {
82
+ gap: ${spacing.large};
83
+ padding: ${spacing.small} ${spacing.normal} ${spacing.small} ${spacing.medium};
84
+ }
85
+ ${mq.range({ from: breakpoints.desktop })} {
86
+ gap: ${spacingUnit * 3};
87
+ }
88
+ display: flex;
89
+ align-items: center;
90
+ > div:first-of-type {
91
+ flex-grow: 1;
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ }
96
+ &:empty {
97
+ display: none;
98
+ }
99
+ border-radius: ${misc.borderRadius};
100
+ @keyframes snackbar-animations-in {
101
+ 0% {
102
+ opacity: 0;
103
+ transform: translateY(${spacing.medium});
104
+ }
105
+ 100% {
106
+ opacity: 1;
107
+ }
108
+ }
109
+ @keyframes snackbar-animations-out {
110
+ 0% {
111
+ opacity: 1;
112
+ }
113
+ 100% {
114
+ opacity: 0;
115
+ transform: translateY(${spacing.medium});
116
+ }
117
+ }
118
+ animation: ${(props) => (props.expired ? 'snackbar-animations-out' : 'snackbar-animations-in')} 200ms ease-in-out;
119
+ animation-fill-mode: forwards;
120
+ ${fonts.sizes('18px')};
121
+ font-family: ${fonts.sans};
122
+ `;
123
+
124
+ export interface SnackBarItem {
125
+ children?: ReactElement;
126
+ snackbarItemId?: string;
127
+ }
128
+
129
+ interface SnackBarProps extends SnackBarItem {
130
+ id: string;
131
+ onKill?: (id: string | undefined) => void;
132
+ actionButtons?: {
133
+ text: string;
134
+ onClick: () => void;
135
+ ariaLabel: string;
136
+ }[];
137
+ }
138
+
139
+ const SnackBar = ({ onKill, children, snackbarItemId, id, actionButtons }: SnackBarProps) => {
140
+ const { t } = useTranslation();
141
+ const [expired, setExpired] = useState(false);
142
+ const timeoutId = useRef<null | ReturnType<typeof setTimeout>>();
143
+ useEffect(() => {
144
+ if (timeoutId.current) {
145
+ timeoutId && clearTimeout(timeoutId.current);
146
+ }
147
+ timeoutId.current = setTimeout(() => {
148
+ setExpired(true);
149
+ }, 8000);
150
+
151
+ return () => {
152
+ timeoutId.current && clearTimeout(timeoutId.current);
153
+ };
154
+ }, [snackbarItemId, timeoutId]);
155
+ return (
156
+ <Wrapper>
157
+ <StyledNotification
158
+ id={id}
159
+ aria-live="polite"
160
+ expired={expired || !children}
161
+ onAnimationEnd={() => expired && onKill && onKill(snackbarItemId)}>
162
+ {children && (
163
+ <>
164
+ <div>{children}</div>
165
+ <WrapperForButtons>
166
+ {actionButtons &&
167
+ actionButtons.map(({ onClick, text, ariaLabel }) => (
168
+ <StyledActionButton key={text} link aria-label={ariaLabel} onClick={onClick}>
169
+ {text}
170
+ </StyledActionButton>
171
+ ))}
172
+ <StyledIconButton aria-label={t('snackbar.close')} size="xsmall" outline onClick={() => setExpired(true)}>
173
+ <Cross />
174
+ </StyledIconButton>
175
+ </WrapperForButtons>
176
+ </>
177
+ )}
178
+ </StyledNotification>
179
+ </Wrapper>
180
+ );
181
+ };
182
+
183
+ export default SnackBar;
@@ -0,0 +1,13 @@
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 SnackBar from './SnackBar';
10
+
11
+ export type { SnackBarItem } from './SnackBar';
12
+
13
+ export { SnackBar };
@@ -0,0 +1,230 @@
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, { useState, useRef, useEffect, ReactNode, RefObject, ChangeEvent, KeyboardEvent } from 'react';
10
+ import { isMobile } from 'react-device-detect';
11
+ import { useTranslation } from 'react-i18next';
12
+ import styled from '@emotion/styled';
13
+ import Button, { IconButtonDualStates } from '@ndla/button';
14
+ import { ChevronDown, ChevronUp } from '@ndla/icons/common';
15
+ import { Cross as CrossRaw } from '@ndla/icons/action';
16
+ import { spacing, colors, misc, animations, fonts } from '@ndla/core';
17
+ import Tooltip from '@ndla/tooltip';
18
+ import { uuid } from '@ndla/util';
19
+ import Suggestions from './Suggestions';
20
+ import type { TagType } from './TagSelector';
21
+
22
+ const Cross = styled(CrossRaw)`
23
+ margin-left: ${spacing.xxsmall};
24
+ `;
25
+
26
+ const SuggestionInputContainer = styled.div`
27
+ margin-bottom: ${spacing.large};
28
+ `;
29
+
30
+ const StyledInput = styled.input`
31
+ flex-grow: 1;
32
+ border: 0;
33
+ outline: none;
34
+ background: transparent;
35
+ ${fonts.sizes(18)};
36
+ `;
37
+ const StyledInputWrapper = styled.div`
38
+ display: flex;
39
+ flex-wrap: wrap;
40
+ gap: ${spacing.xsmall};
41
+ padding: ${spacing.small};
42
+ border: 1px solid ${colors.brand.greyLighter};
43
+ transition: border-color ${animations.durations.normal} ease;
44
+ border-radius: ${misc.borderRadius};
45
+ &:focus-within {
46
+ border-color: ${colors.brand.primary};
47
+ }
48
+ `;
49
+
50
+ const CombinedInputAndDropdownWrapper = styled.div`
51
+ display: flex;
52
+ flex-grow: 1;
53
+ `;
54
+
55
+ interface SuggestionInputProps {
56
+ suggestions: TagType[];
57
+ value: string;
58
+ onChange: (e: ChangeEvent<HTMLInputElement>) => void;
59
+ setExpanded: (expanded: boolean) => void;
60
+ expanded: boolean;
61
+ onToggleTag: (id: string) => void;
62
+ setInputValue: (value: string) => void;
63
+ onCreateTag: (tagName: string) => void;
64
+ addedTags: TagType[];
65
+ dropdownMaxHeight: string;
66
+ prefix?: string | ReactNode;
67
+ inline?: boolean;
68
+ scrollAnchorElement: RefObject<HTMLDivElement>;
69
+ }
70
+
71
+ const SuggestionInput = ({
72
+ suggestions,
73
+ value,
74
+ setInputValue,
75
+ onCreateTag,
76
+ onChange,
77
+ onToggleTag,
78
+ addedTags,
79
+ setExpanded,
80
+ expanded,
81
+ dropdownMaxHeight,
82
+ prefix,
83
+ inline,
84
+ scrollAnchorElement,
85
+ }: SuggestionInputProps) => {
86
+ const { t } = useTranslation();
87
+ const [currentHighlightedIndex, setCurrentHighlightedIndex] = useState(0);
88
+ const [hasFocus, setHasFocus] = useState(false);
89
+ const initalRender = useRef(true);
90
+ const inputRef = useRef<HTMLInputElement>(null);
91
+ const containerRef = useRef<HTMLDivElement>(null);
92
+ const suggestionIdRef = useRef<string>(uuid());
93
+
94
+ useEffect(() => {
95
+ setCurrentHighlightedIndex(0);
96
+ }, [suggestions]);
97
+
98
+ useEffect(() => {
99
+ if (!initalRender.current) {
100
+ inputRef.current?.focus();
101
+ } else {
102
+ initalRender.current = false;
103
+ }
104
+ }, [addedTags]);
105
+
106
+ useEffect(() => {
107
+ const selectedSuggestionElement = document
108
+ .getElementById(suggestionIdRef.current)
109
+ ?.querySelector('[aria-selected="true"]');
110
+ if (selectedSuggestionElement) {
111
+ // Do we need to scroll this into view?
112
+ selectedSuggestionElement.scrollIntoView({
113
+ behavior: 'smooth',
114
+ block: 'nearest',
115
+ });
116
+ }
117
+ }, [currentHighlightedIndex]);
118
+
119
+ const hasBeenAdded = (id: string) => addedTags.some(({ id: idAdded }) => idAdded === id);
120
+
121
+ return (
122
+ <SuggestionInputContainer ref={containerRef}>
123
+ <StyledInputWrapper>
124
+ {addedTags.map(({ id, name }) => (
125
+ <Button
126
+ aria-label={t('tagSelector.removeTag', { name })}
127
+ onClick={() => onToggleTag(id)}
128
+ light
129
+ borderShape="rounded"
130
+ key={id}
131
+ size="small">
132
+ {prefix}
133
+ {name}
134
+ <Cross />
135
+ </Button>
136
+ ))}
137
+ <CombinedInputAndDropdownWrapper>
138
+ <StyledInput
139
+ placeholder={t('tagSelector.placeholder')}
140
+ value={value}
141
+ autoComplete="off"
142
+ onBlur={(e) => {
143
+ const relatedTarget = e.relatedTarget as HTMLElement;
144
+ if (!relatedTarget?.dataset?.suggestionbutton) {
145
+ setExpanded(false);
146
+ setHasFocus(false);
147
+ }
148
+ }}
149
+ onChange={onChange}
150
+ onFocus={() => {
151
+ if (isMobile && scrollAnchorElement?.current) {
152
+ scrollAnchorElement.current.scrollIntoView({
153
+ behavior: 'smooth',
154
+ });
155
+ }
156
+ setHasFocus(true);
157
+ }}
158
+ ref={inputRef}
159
+ onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
160
+ if (e.key === 'Escape') {
161
+ setExpanded(false);
162
+ e.preventDefault();
163
+ } else if (e.key === 'Enter' || e.key === 'Tab') {
164
+ if (value !== '' || expanded) {
165
+ if (suggestions.length > 0) {
166
+ if (!hasBeenAdded(suggestions[currentHighlightedIndex].id)) {
167
+ onToggleTag(suggestions[currentHighlightedIndex].id);
168
+ }
169
+ setInputValue('');
170
+ if (e.key === 'Enter') {
171
+ e.preventDefault();
172
+ }
173
+ } else {
174
+ onCreateTag(value);
175
+ setInputValue('');
176
+ e.preventDefault();
177
+ }
178
+ } else if (e.key === 'Enter') {
179
+ e.preventDefault();
180
+ }
181
+ } else if (e.key === 'ArrowUp') {
182
+ setCurrentHighlightedIndex(
183
+ currentHighlightedIndex - 1 < 0 ? suggestions.length - 1 : currentHighlightedIndex - 1,
184
+ );
185
+ e.preventDefault();
186
+ } else if (e.key === 'ArrowDown') {
187
+ setCurrentHighlightedIndex(
188
+ currentHighlightedIndex + 1 >= suggestions.length ? 0 : currentHighlightedIndex + 1,
189
+ );
190
+ e.preventDefault();
191
+ }
192
+ }}
193
+ />
194
+ <Tooltip tooltip={expanded ? t('tagSelector.hideAllTags') : t('tagSelector.showAllTags')}>
195
+ <IconButtonDualStates
196
+ data-suggestionbutton
197
+ ariaLabelActive={t('tagSelector.showAllTags')}
198
+ ariaLabelInActive={t('tagSelector.hideAllTags')}
199
+ active={expanded}
200
+ greyLighter
201
+ inactiveIcon={<ChevronDown />}
202
+ activeIcon={<ChevronUp />}
203
+ size="small"
204
+ aria-controls={suggestionIdRef.current}
205
+ onClick={() => {
206
+ setInputValue('');
207
+ setExpanded(!expanded);
208
+ inputRef.current?.focus();
209
+ }}
210
+ />
211
+ </Tooltip>
212
+ </CombinedInputAndDropdownWrapper>
213
+ </StyledInputWrapper>
214
+ <div id={suggestionIdRef.current} aria-live="polite">
215
+ {(hasFocus || expanded) && suggestions.length > 0 ? (
216
+ <Suggestions
217
+ inline={inline}
218
+ dropdownMaxHeight={dropdownMaxHeight}
219
+ suggestions={suggestions}
220
+ currentHighlightedIndex={currentHighlightedIndex}
221
+ onToggleTag={onToggleTag}
222
+ hasBeenAdded={hasBeenAdded}
223
+ />
224
+ ) : null}
225
+ </div>
226
+ </SuggestionInputContainer>
227
+ );
228
+ };
229
+
230
+ export default SuggestionInput;
@@ -0,0 +1,125 @@
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 from 'react';
10
+ import styled from '@emotion/styled';
11
+ import { Check } from '@ndla/icons/editor';
12
+ import { spacing, colors, misc, animations, fonts, shadows } from '@ndla/core';
13
+ import Button from '@ndla/button';
14
+ import type { TagType } from './TagSelector';
15
+
16
+ const ABSOLUTE_DROPDOWN_MAXHEIGHT = '360px';
17
+
18
+ const CheckedIcon = styled(Check)`
19
+ width: ${spacing.normal};
20
+ height: ${spacing.normal};
21
+ fill: ${colors.brand.light};
22
+ `;
23
+
24
+ interface SuggestionsWrapperProps {
25
+ dropdownMaxHeight: string;
26
+ inline?: boolean;
27
+ }
28
+
29
+ const SuggestionsWrapper = styled.div`
30
+ position: relative;
31
+ `;
32
+
33
+ const Suggestions = styled.div<SuggestionsWrapperProps>`
34
+ position: ${({ inline }) => (inline ? 'static' : 'absolute')};
35
+ z-index: 99999;
36
+ right: 0;
37
+ left: 0;
38
+ box-shadow: ${shadows.levitate1};
39
+ margin: 0 ${spacing.small};
40
+ padding: ${spacing.small} 0;
41
+ overflow-y: scroll;
42
+ scroll-behavior: smooth;
43
+ max-height: min(${({ dropdownMaxHeight }) => dropdownMaxHeight}, ${ABSOLUTE_DROPDOWN_MAXHEIGHT});
44
+ border-radius: ${misc.borderRadius};
45
+ background: ${colors.white};
46
+ ${animations.fadeIn(animations.durations.fast)}
47
+ `;
48
+
49
+ const SuggestionList = styled.div`
50
+ opacity: 0;
51
+ ${animations.fadeInBottom()}
52
+ animation-delay: ${animations.durations.fast};
53
+ animation-fill-mode: forwards;
54
+ `;
55
+
56
+ interface SuggestionButtonProps {
57
+ isHighlighted: boolean;
58
+ }
59
+
60
+ const SuggestionButton = styled(Button)<SuggestionButtonProps>`
61
+ display: flex;
62
+ justify-content: space-between;
63
+ ${fonts.sizes(18)};
64
+ transition: ${misc.transition.default};
65
+ font-weight: 400;
66
+
67
+ &:disabled {
68
+ color: ${colors.brand.greyMedium};
69
+ &:hover {
70
+ svg {
71
+ fill: ${colors.brand.greyLight};
72
+ }
73
+ }
74
+ }
75
+ `;
76
+
77
+ interface Props {
78
+ inline?: boolean;
79
+ dropdownMaxHeight: string;
80
+ suggestions: TagType[];
81
+ currentHighlightedIndex: number;
82
+ onToggleTag: (id: string) => void;
83
+ hasBeenAdded: (id: string) => boolean;
84
+ }
85
+
86
+ const TagSuggestions = ({
87
+ inline,
88
+ dropdownMaxHeight,
89
+ suggestions,
90
+ currentHighlightedIndex,
91
+ onToggleTag,
92
+ hasBeenAdded,
93
+ }: Props) => (
94
+ <SuggestionsWrapper>
95
+ <Suggestions inline={inline} dropdownMaxHeight={dropdownMaxHeight}>
96
+ <SuggestionList role="listbox">
97
+ {suggestions.map(({ id, name }, index: number) => {
98
+ const alreadyAdded = hasBeenAdded(id);
99
+ const selected = index === currentHighlightedIndex;
100
+ return (
101
+ <SuggestionButton
102
+ borderShape="sharpened"
103
+ ghostPill
104
+ width="full"
105
+ textAlign="left"
106
+ data-suggestionbutton
107
+ role="option"
108
+ aria-selected={selected}
109
+ disabled={alreadyAdded}
110
+ isHighlighted={selected}
111
+ onMouseDown={() => {
112
+ onToggleTag(id);
113
+ }}
114
+ key={id}>
115
+ <span>{name}</span>
116
+ {alreadyAdded && <CheckedIcon />}
117
+ </SuggestionButton>
118
+ );
119
+ })}
120
+ </SuggestionList>
121
+ </Suggestions>
122
+ </SuggestionsWrapper>
123
+ );
124
+
125
+ export default TagSuggestions;