@ndla/ui 14.0.0 → 15.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 (189) 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/{lib/MyNdla/ResourceDash/ResourcesView.d.ts → es/MyNdla/Resource/index.js} +2 -3
  12. package/es/MyNdla/index.js +3 -4
  13. package/es/Resource/BlockResource.js +73 -0
  14. package/es/Resource/ListResource.js +66 -0
  15. package/es/Resource/index.js +10 -0
  16. package/es/Resource/resourceComponents.js +97 -0
  17. package/es/ResourceGroup/ResourceGroup.js +7 -5
  18. package/es/ResourceGroup/ResourceItem.js +25 -24
  19. package/es/ResourceGroup/ResourceList.js +18 -6
  20. package/es/SnackBar/SnackBar.js +117 -0
  21. package/es/SnackBar/index.js +9 -0
  22. package/es/TagSelector/SuggestionInput.js +240 -0
  23. package/es/TagSelector/Suggestions.js +93 -0
  24. package/es/TagSelector/TagSelector.js +137 -0
  25. package/es/TagSelector/index.js +9 -0
  26. package/es/TreeStructure/FolderItem.js +130 -0
  27. package/es/TreeStructure/FolderItems.js +123 -0
  28. package/es/TreeStructure/FolderNameInput.js +112 -0
  29. package/es/TreeStructure/TreeStructure.js +254 -0
  30. package/es/TreeStructure/TreeStructure.types.js +0 -0
  31. package/es/TreeStructure/TreeStructureWrapper.js +13 -0
  32. package/es/TreeStructure/helperFunctions.js +92 -0
  33. package/es/TreeStructure/index.js +9 -0
  34. package/es/TreeStructure/keyboardNavigation/keyboardNavigation.js +182 -0
  35. package/es/TreeStructure/keyboardNavigation/keyboardNavigation.types.js +0 -0
  36. package/es/all.css +72 -0
  37. package/es/index.js +8 -3
  38. package/es/locale/messages-en.js +62 -4
  39. package/es/locale/messages-nb.js +61 -3
  40. package/es/locale/messages-nn.js +61 -3
  41. package/es/locale/messages-se.js +61 -3
  42. package/es/locale/messages-sma.js +61 -3
  43. package/lib/Article/Article.d.ts +3 -1
  44. package/lib/Article/Article.js +43 -23
  45. package/lib/Article/ArticleFavoritesButton.d.ts +15 -0
  46. package/lib/Article/ArticleFavoritesButton.js +56 -0
  47. package/lib/Article/index.d.ts +2 -1
  48. package/lib/Article/index.js +8 -0
  49. package/lib/Breadcrumb/ActionBreadcrumb.d.ts +16 -0
  50. package/lib/Breadcrumb/ActionBreadcrumb.js +72 -0
  51. package/lib/Breadcrumb/index.d.ts +1 -0
  52. package/lib/Breadcrumb/index.js +8 -0
  53. package/lib/InfoBlock/InfoBlock.d.ts +8 -0
  54. package/lib/InfoBlock/InfoBlock.js +58 -0
  55. package/lib/InfoBlock/index.d.ts +1 -0
  56. package/lib/InfoBlock/index.js +13 -0
  57. package/lib/MyNdla/Navigation/VerticalNavigation.d.ts +10 -0
  58. package/lib/MyNdla/Navigation/VerticalNavigation.js +61 -0
  59. package/lib/MyNdla/Navigation/index.d.ts +2 -0
  60. package/lib/MyNdla/Navigation/index.js +15 -0
  61. package/lib/MyNdla/Resource/Folder.d.ts +20 -0
  62. package/lib/MyNdla/Resource/Folder.js +100 -0
  63. package/lib/MyNdla/Resource/index.d.ts +9 -0
  64. package/lib/MyNdla/Resource/index.js +15 -0
  65. package/lib/MyNdla/index.d.ts +3 -4
  66. package/lib/MyNdla/index.js +9 -11
  67. package/lib/Resource/BlockResource.d.ts +20 -0
  68. package/lib/Resource/BlockResource.js +84 -0
  69. package/lib/Resource/ListResource.d.ts +20 -0
  70. package/lib/Resource/ListResource.js +78 -0
  71. package/lib/Resource/index.d.ts +11 -0
  72. package/lib/Resource/index.js +29 -0
  73. package/lib/Resource/resourceComponents.d.ts +24 -0
  74. package/lib/Resource/resourceComponents.js +106 -0
  75. package/lib/ResourceGroup/ResourceGroup.d.ts +2 -1
  76. package/lib/ResourceGroup/ResourceGroup.js +7 -5
  77. package/lib/ResourceGroup/ResourceItem.d.ts +5 -1
  78. package/lib/ResourceGroup/ResourceItem.js +26 -24
  79. package/lib/ResourceGroup/ResourceList.d.ts +3 -1
  80. package/lib/ResourceGroup/ResourceList.js +18 -6
  81. package/lib/SnackBar/SnackBar.d.ts +23 -0
  82. package/lib/SnackBar/SnackBar.js +127 -0
  83. package/lib/SnackBar/index.d.ts +10 -0
  84. package/lib/SnackBar/index.js +15 -0
  85. package/lib/TagSelector/SuggestionInput.d.ts +19 -0
  86. package/lib/TagSelector/SuggestionInput.js +255 -0
  87. package/lib/TagSelector/Suggestions.d.ts +12 -0
  88. package/lib/TagSelector/Suggestions.js +96 -0
  89. package/lib/TagSelector/TagSelector.d.ts +16 -0
  90. package/lib/TagSelector/TagSelector.js +150 -0
  91. package/lib/TagSelector/index.d.ts +10 -0
  92. package/lib/TagSelector/index.js +19 -0
  93. package/lib/TreeStructure/FolderItem.d.ts +27 -0
  94. package/lib/TreeStructure/FolderItem.js +140 -0
  95. package/lib/TreeStructure/FolderItems.d.ts +11 -0
  96. package/lib/TreeStructure/FolderItems.js +130 -0
  97. package/lib/TreeStructure/FolderNameInput.d.ts +15 -0
  98. package/lib/TreeStructure/FolderNameInput.js +125 -0
  99. package/lib/TreeStructure/TreeStructure.d.ts +12 -0
  100. package/lib/TreeStructure/TreeStructure.js +273 -0
  101. package/lib/TreeStructure/TreeStructure.types.d.ts +63 -0
  102. package/lib/TreeStructure/TreeStructure.types.js +1 -0
  103. package/lib/TreeStructure/TreeStructureWrapper.d.ts +12 -0
  104. package/lib/TreeStructure/TreeStructureWrapper.js +24 -0
  105. package/lib/TreeStructure/helperFunctions.d.ts +5 -0
  106. package/lib/TreeStructure/helperFunctions.js +103 -0
  107. package/lib/TreeStructure/index.d.ts +10 -0
  108. package/lib/TreeStructure/index.js +15 -0
  109. package/lib/TreeStructure/keyboardNavigation/keyboardNavigation.d.ts +11 -0
  110. package/lib/TreeStructure/keyboardNavigation/keyboardNavigation.js +186 -0
  111. package/lib/TreeStructure/keyboardNavigation/keyboardNavigation.types.d.ts +26 -0
  112. package/lib/TreeStructure/keyboardNavigation/keyboardNavigation.types.js +1 -0
  113. package/lib/User/apiTypes.d.ts +1 -1
  114. package/lib/User/index.d.ts +2 -2
  115. package/lib/all.css +72 -0
  116. package/lib/index.d.ts +13 -4
  117. package/lib/index.js +68 -9
  118. package/lib/locale/messages-en.d.ts +58 -0
  119. package/lib/locale/messages-en.js +62 -4
  120. package/lib/locale/messages-nb.d.ts +58 -0
  121. package/lib/locale/messages-nb.js +61 -3
  122. package/lib/locale/messages-nn.d.ts +58 -0
  123. package/lib/locale/messages-nn.js +61 -3
  124. package/lib/locale/messages-se.d.ts +58 -0
  125. package/lib/locale/messages-se.js +61 -3
  126. package/lib/locale/messages-sma.d.ts +58 -0
  127. package/lib/locale/messages-sma.js +61 -3
  128. package/lib/types.d.ts +1 -1
  129. package/package.json +11 -11
  130. package/src/Article/Article.tsx +31 -0
  131. package/src/Article/ArticleFavoritesButton.tsx +40 -0
  132. package/src/Article/index.ts +2 -0
  133. package/src/Breadcrumb/ActionBreadcrumb.tsx +68 -0
  134. package/src/Breadcrumb/index.ts +2 -0
  135. package/src/InfoBlock/InfoBlock.tsx +61 -0
  136. package/src/InfoBlock/index.ts +1 -0
  137. package/src/MyNdla/Navigation/VerticalNavigation.tsx +93 -0
  138. package/src/MyNdla/Navigation/index.ts +2 -0
  139. package/src/MyNdla/Resource/Folder.tsx +143 -0
  140. package/src/MyNdla/Resource/index.ts +10 -0
  141. package/src/MyNdla/index.ts +3 -5
  142. package/src/Resource/BlockResource.tsx +101 -0
  143. package/src/Resource/ListResource.tsx +111 -0
  144. package/src/Resource/index.ts +12 -0
  145. package/src/Resource/resourceComponents.tsx +143 -0
  146. package/src/ResourceGroup/ResourceGroup.tsx +3 -0
  147. package/src/ResourceGroup/ResourceItem.tsx +17 -0
  148. package/src/ResourceGroup/ResourceList.tsx +16 -3
  149. package/src/SnackBar/SnackBar.tsx +183 -0
  150. package/src/SnackBar/index.ts +13 -0
  151. package/src/TagSelector/SuggestionInput.tsx +230 -0
  152. package/src/TagSelector/Suggestions.tsx +125 -0
  153. package/src/TagSelector/TagSelector.tsx +111 -0
  154. package/src/TagSelector/index.ts +13 -0
  155. package/src/TreeStructure/FolderItem.tsx +160 -0
  156. package/src/TreeStructure/FolderItems.tsx +109 -0
  157. package/src/TreeStructure/FolderNameInput.tsx +109 -0
  158. package/src/TreeStructure/TreeStructure.tsx +184 -0
  159. package/src/TreeStructure/TreeStructure.types.ts +69 -0
  160. package/src/TreeStructure/TreeStructureWrapper.tsx +34 -0
  161. package/src/TreeStructure/helperFunctions.ts +52 -0
  162. package/src/TreeStructure/index.ts +11 -0
  163. package/src/TreeStructure/keyboardNavigation/keyboardNavigation.ts +161 -0
  164. package/src/TreeStructure/keyboardNavigation/keyboardNavigation.types.ts +28 -0
  165. package/src/User/apiTypes.ts +1 -1
  166. package/src/User/index.ts +2 -2
  167. package/src/all.scss +1 -0
  168. package/src/index.ts +14 -5
  169. package/src/locale/messages-en.ts +56 -3
  170. package/src/locale/messages-nb.ts +55 -2
  171. package/src/locale/messages-nn.ts +55 -2
  172. package/src/locale/messages-se.ts +55 -2
  173. package/src/locale/messages-sma.ts +55 -2
  174. package/src/types.ts +1 -1
  175. package/es/MyNdla/ResourceDash/Breadcrumbs.js +0 -22
  176. package/es/MyNdla/ResourceDash/ResourceElement.js +0 -27
  177. package/es/MyNdla/ResourceDash/ResourcesView.js +0 -43
  178. package/es/MyNdla/ResourceDash/index.js +0 -4
  179. package/lib/MyNdla/ResourceDash/Breadcrumbs.d.ts +0 -15
  180. package/lib/MyNdla/ResourceDash/Breadcrumbs.js +0 -35
  181. package/lib/MyNdla/ResourceDash/ResourceElement.d.ts +0 -18
  182. package/lib/MyNdla/ResourceDash/ResourceElement.js +0 -38
  183. package/lib/MyNdla/ResourceDash/ResourcesView.js +0 -57
  184. package/lib/MyNdla/ResourceDash/index.d.ts +0 -4
  185. package/lib/MyNdla/ResourceDash/index.js +0 -31
  186. package/src/MyNdla/ResourceDash/Breadcrumbs.tsx +0 -31
  187. package/src/MyNdla/ResourceDash/ResourceElement.tsx +0 -50
  188. package/src/MyNdla/ResourceDash/ResourcesView.tsx +0 -42
  189. package/src/MyNdla/ResourceDash/index.ts +0 -5
@@ -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;
@@ -0,0 +1,111 @@
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, ChangeEvent } from 'react';
10
+ import styled from '@emotion/styled';
11
+ import { spacingUnit, fonts } from '@ndla/core';
12
+ import { uuid } from '@ndla/util';
13
+ import SuggestionInput from './SuggestionInput';
14
+
15
+ const DEFAULT_DROPDOWN_MAXHEIGHT = '240px';
16
+
17
+ const StyledLabel = styled.label`
18
+ font-weight: ${fonts.weight.semibold};
19
+ `;
20
+
21
+ export interface TagType {
22
+ name: string;
23
+ id: string;
24
+ }
25
+
26
+ interface Props {
27
+ label: string;
28
+ tags: TagType[];
29
+ tagsSelected: string[];
30
+ onToggleTag: (id: string) => void;
31
+ onCreateTag: (tagName: string) => void;
32
+ inline?: boolean;
33
+ prefix?: string;
34
+ }
35
+
36
+ const sortedTags = (tags: TagType[], selectedTags: string[]): TagType[] =>
37
+ tags
38
+ .filter(({ id }) => selectedTags.some((idSelected) => idSelected === id))
39
+ .sort((a, b) => a.name.localeCompare(b.name, 'nb'));
40
+
41
+ const getSuggestions = (tags: TagType[], inputValue: string): TagType[] => {
42
+ if (inputValue === '') {
43
+ return [];
44
+ }
45
+ const inputLowercase = inputValue.toLowerCase();
46
+ return tags
47
+ .filter(({ name }) => name.toLowerCase().startsWith(inputLowercase))
48
+ .sort((a, b) => a.name.localeCompare(b.name, 'nb'));
49
+ };
50
+
51
+ const TagSelector = ({ label, tags, tagsSelected, onCreateTag, onToggleTag, inline, prefix }: Props) => {
52
+ const [inputValue, setInputValue] = useState('');
53
+ const [expanded, setExpanded] = useState(false);
54
+ const [dropdownMaxHeight, setDropdownMaxHeight] = useState(DEFAULT_DROPDOWN_MAXHEIGHT);
55
+ const containerRef = useRef<HTMLDivElement>(null);
56
+ const inputIdRef = useRef<string>(uuid());
57
+
58
+ useEffect(() => {
59
+ setExpanded(false);
60
+ }, [tagsSelected]);
61
+
62
+ useEffect(() => {
63
+ const setMaxDropdownMaxHeight = () => {
64
+ if (!inline && containerRef.current && typeof window !== 'undefined') {
65
+ // Calculate distance from bottom of container to bottom of viewport
66
+ const containerBottom = containerRef.current.getBoundingClientRect().bottom;
67
+ const viewportBottom = document.documentElement.scrollHeight;
68
+ const maxDropdownHeight = viewportBottom - containerBottom;
69
+ setDropdownMaxHeight(`${maxDropdownHeight - spacingUnit}px`);
70
+ }
71
+ };
72
+ if (!inline && typeof window !== 'undefined') {
73
+ if (expanded) {
74
+ setMaxDropdownMaxHeight();
75
+ window.addEventListener('resize', setMaxDropdownMaxHeight);
76
+ } else {
77
+ window.removeEventListener('resize', setMaxDropdownMaxHeight);
78
+ }
79
+ }
80
+ return () => {
81
+ typeof window !== 'undefined' && window.removeEventListener('resize', setMaxDropdownMaxHeight);
82
+ };
83
+ }, [expanded, inline]);
84
+
85
+ return (
86
+ <div ref={containerRef}>
87
+ <StyledLabel htmlFor={inputIdRef.current}>{label}</StyledLabel>
88
+ <SuggestionInput
89
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
90
+ const target = e.target as HTMLInputElement;
91
+ setInputValue(target.value);
92
+ setExpanded(false);
93
+ }}
94
+ suggestions={expanded ? tags : getSuggestions(tags, inputValue)}
95
+ value={inputValue}
96
+ onCreateTag={onCreateTag}
97
+ onToggleTag={onToggleTag}
98
+ setInputValue={setInputValue}
99
+ addedTags={sortedTags(tags, tagsSelected)}
100
+ expanded={expanded}
101
+ setExpanded={setExpanded}
102
+ dropdownMaxHeight={dropdownMaxHeight}
103
+ inline={inline}
104
+ scrollAnchorElement={containerRef}
105
+ prefix={prefix}
106
+ />
107
+ </div>
108
+ );
109
+ };
110
+
111
+ export default TagSelector;
@@ -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 TagSelector, { TagType } from './TagSelector';
10
+
11
+ export type { TagType };
12
+
13
+ export { TagSelector };