@stack-spot/portal-components 2.0.2 → 2.1.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 (129) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/components/AnimatedHeight.d.ts +59 -0
  3. package/dist/components/AnimatedHeight.d.ts.map +1 -0
  4. package/dist/components/AnimatedHeight.js +105 -0
  5. package/dist/components/AnimatedHeight.js.map +1 -0
  6. package/dist/components/Placeholder.d.ts +6 -4
  7. package/dist/components/Placeholder.d.ts.map +1 -1
  8. package/dist/components/Placeholder.js +5 -4
  9. package/dist/components/Placeholder.js.map +1 -1
  10. package/dist/components/TimelineSection.d.ts +25 -0
  11. package/dist/components/TimelineSection.d.ts.map +1 -0
  12. package/dist/components/TimelineSection.js +27 -0
  13. package/dist/components/TimelineSection.js.map +1 -0
  14. package/dist/components/error/ErrorFeedback.d.ts +9 -1
  15. package/dist/components/error/ErrorFeedback.d.ts.map +1 -1
  16. package/dist/components/error/ErrorFeedback.js +41 -4
  17. package/dist/components/error/ErrorFeedback.js.map +1 -1
  18. package/dist/components/form/SearchInput.d.ts +9 -0
  19. package/dist/components/form/SearchInput.d.ts.map +1 -0
  20. package/dist/components/form/SearchInput.js +28 -0
  21. package/dist/components/form/SearchInput.js.map +1 -0
  22. package/dist/components/form/Select.d.ts +69 -0
  23. package/dist/components/form/Select.d.ts.map +1 -0
  24. package/dist/components/form/Select.js +161 -0
  25. package/dist/components/form/Select.js.map +1 -0
  26. package/dist/components/{Notifications → notification}/NotificationComponent.d.ts +2 -1
  27. package/dist/components/notification/NotificationComponent.d.ts.map +1 -0
  28. package/dist/components/{Notifications → notification}/NotificationComponent.js +12 -4
  29. package/dist/components/notification/NotificationComponent.js.map +1 -0
  30. package/dist/components/notification/NotificationItem.d.ts +42 -0
  31. package/dist/components/notification/NotificationItem.d.ts.map +1 -0
  32. package/dist/components/{Notifications → notification}/NotificationItem.js +27 -12
  33. package/dist/components/notification/NotificationItem.js.map +1 -0
  34. package/dist/components/notification/NotificationList.d.ts +39 -0
  35. package/dist/components/notification/NotificationList.d.ts.map +1 -0
  36. package/dist/components/notification/NotificationList.js +82 -0
  37. package/dist/components/notification/NotificationList.js.map +1 -0
  38. package/dist/components/notification/NotificationPlaceholder.d.ts +12 -0
  39. package/dist/components/notification/NotificationPlaceholder.d.ts.map +1 -0
  40. package/dist/components/notification/NotificationPlaceholder.js +22 -0
  41. package/dist/components/notification/NotificationPlaceholder.js.map +1 -0
  42. package/dist/components/{Notifications → notification}/types.d.ts +16 -0
  43. package/dist/components/notification/types.d.ts.map +1 -0
  44. package/dist/components/{Notifications → notification}/types.js +3 -0
  45. package/dist/components/notification/types.js.map +1 -0
  46. package/dist/containers/NotificationsPage.d.ts +2 -0
  47. package/dist/containers/NotificationsPage.d.ts.map +1 -0
  48. package/dist/containers/NotificationsPage.js +58 -0
  49. package/dist/containers/NotificationsPage.js.map +1 -0
  50. package/dist/context/notification/LazyNotificationList.d.ts +28 -0
  51. package/dist/context/notification/LazyNotificationList.d.ts.map +1 -0
  52. package/dist/context/notification/LazyNotificationList.js +128 -0
  53. package/dist/context/notification/LazyNotificationList.js.map +1 -0
  54. package/dist/context/notification/NotificationController.d.ts +24 -0
  55. package/dist/context/notification/NotificationController.d.ts.map +1 -0
  56. package/dist/context/notification/NotificationController.js +136 -0
  57. package/dist/context/notification/NotificationController.js.map +1 -0
  58. package/dist/context/notification/context.d.ts +9 -0
  59. package/dist/context/notification/context.d.ts.map +1 -0
  60. package/dist/context/notification/context.js +12 -0
  61. package/dist/context/notification/context.js.map +1 -0
  62. package/dist/context/notification/hooks.d.ts +13 -0
  63. package/dist/context/notification/hooks.d.ts.map +1 -0
  64. package/dist/context/notification/hooks.js +77 -0
  65. package/dist/context/notification/hooks.js.map +1 -0
  66. package/dist/context/notification/types.d.ts +57 -0
  67. package/dist/context/notification/types.d.ts.map +1 -0
  68. package/dist/context/notification/types.js +2 -0
  69. package/dist/context/notification/types.js.map +1 -0
  70. package/dist/hooks/manual-render.d.ts +8 -0
  71. package/dist/hooks/manual-render.d.ts.map +1 -0
  72. package/dist/hooks/manual-render.js +10 -0
  73. package/dist/hooks/manual-render.js.map +1 -0
  74. package/dist/index.d.ts +2 -0
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +2 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/notifications.d.ts +11 -0
  79. package/dist/notifications.d.ts.map +1 -0
  80. package/dist/notifications.js +10 -0
  81. package/dist/notifications.js.map +1 -0
  82. package/dist/svg/GenericPlaceholder.d.ts +5 -0
  83. package/dist/svg/GenericPlaceholder.d.ts.map +1 -0
  84. package/dist/svg/GenericPlaceholder.js +4 -0
  85. package/dist/svg/GenericPlaceholder.js.map +1 -0
  86. package/dist/svg/index.d.ts +1 -0
  87. package/dist/svg/index.d.ts.map +1 -1
  88. package/dist/svg/index.js +1 -0
  89. package/dist/svg/index.js.map +1 -1
  90. package/dist/utils/promise.d.ts +2 -0
  91. package/dist/utils/promise.d.ts.map +1 -0
  92. package/dist/utils/promise.js +6 -0
  93. package/dist/utils/promise.js.map +1 -0
  94. package/package.json +8 -4
  95. package/src/components/AnimatedHeight.tsx +174 -0
  96. package/src/components/Placeholder.tsx +13 -8
  97. package/src/components/TimelineSection.tsx +54 -0
  98. package/src/components/error/ErrorFeedback.tsx +93 -55
  99. package/src/components/form/SearchInput.tsx +69 -0
  100. package/src/components/form/Select.tsx +264 -0
  101. package/src/components/{Notifications → notification}/NotificationComponent.tsx +13 -5
  102. package/src/components/{Notifications → notification}/NotificationItem.tsx +76 -34
  103. package/src/components/notification/NotificationList.tsx +167 -0
  104. package/src/components/notification/NotificationPlaceholder.tsx +40 -0
  105. package/src/components/{Notifications → notification}/types.ts +21 -0
  106. package/src/containers/NotificationsPage.tsx +98 -0
  107. package/src/context/notification/LazyNotificationList.ts +95 -0
  108. package/src/context/notification/NotificationController.ts +104 -0
  109. package/src/context/notification/context.tsx +23 -0
  110. package/src/context/notification/hooks.ts +82 -0
  111. package/src/context/notification/types.ts +64 -0
  112. package/src/hooks/manual-render.tsx +10 -0
  113. package/src/index.ts +2 -1
  114. package/src/notifications.ts +11 -0
  115. package/src/svg/GenericPlaceholder.tsx +19 -0
  116. package/src/svg/index.ts +1 -0
  117. package/src/utils/promise.ts +5 -0
  118. package/dist/components/Notifications/NotificationComponent.d.ts.map +0 -1
  119. package/dist/components/Notifications/NotificationComponent.js.map +0 -1
  120. package/dist/components/Notifications/NotificationItem.d.ts +0 -17
  121. package/dist/components/Notifications/NotificationItem.d.ts.map +0 -1
  122. package/dist/components/Notifications/NotificationItem.js.map +0 -1
  123. package/dist/components/Notifications/index.d.ts +0 -4
  124. package/dist/components/Notifications/index.d.ts.map +0 -1
  125. package/dist/components/Notifications/index.js +0 -4
  126. package/dist/components/Notifications/index.js.map +0 -1
  127. package/dist/components/Notifications/types.d.ts.map +0 -1
  128. package/dist/components/Notifications/types.js.map +0 -1
  129. package/src/components/Notifications/index.tsx +0 -3
@@ -1,6 +1,8 @@
1
- import { Box, Button, Container, Flex, LinkBox, Text } from '@citric/core'
1
+ import { Button, LinkBox, Text } from '@citric/core'
2
+ import { listToClass, theme } from '@stack-spot/portal-theme'
2
3
  import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
3
4
  import { useState } from 'react'
5
+ import { styled } from 'styled-components'
4
6
  import { Forbidden } from '../../svg/Forbidden'
5
7
  import { Logo } from '../../svg/Logo'
6
8
  import { NotFound } from '../../svg/NotFound'
@@ -55,8 +57,52 @@ export interface ErrorDescription {
55
57
  * The image for the error. Overwrites anything preset by "code".
56
58
  */
57
59
  image?: React.ReactElement,
60
+ /**
61
+ * Whether to show the feedback horizontally (row) or vertically (column).
62
+ *
63
+ * @default 'row'
64
+ */
65
+ direction?: 'row' | 'column',
66
+ style?: React.CSSProperties,
67
+ className?: string,
58
68
  }
59
69
 
70
+ const FeedbackBox = styled.div`
71
+ background-color: ${theme.color.light[400]};
72
+ padding: 24px;
73
+ .content {
74
+ display: flex;
75
+ justify-content: center;
76
+ align-items: center;
77
+ &.row {
78
+ flex-direction: row;
79
+ gap: 40px;
80
+ }
81
+ &.column {
82
+ flex-direction: column;
83
+ .text-content {
84
+ align-items: center;
85
+ }
86
+ }
87
+ .text-content {
88
+ display: flex;
89
+ flex-direction: column;
90
+ gap: 12px;
91
+ }
92
+ .buttons {
93
+ display: flex;
94
+ flex-direction: row;
95
+ gap: '10px';
96
+ }
97
+ .details {
98
+ background-color: ${theme.color.danger[500]};
99
+ color: ${theme.color.danger.contrastText};
100
+ padding: 12px;
101
+ border-radius: 5px;
102
+ }
103
+ }
104
+ `
105
+
60
106
  /**
61
107
  * A box with an icon and an error message. This is used for giving error feedbacks to the user.
62
108
  *
@@ -64,72 +110,64 @@ export interface ErrorDescription {
64
110
  *
65
111
  * @param options the error code, the error message and whether or not the application is in debug mode.
66
112
  */
67
- export const ErrorFeedback = ({ code = 0, message, debug, title, body, image, action, description, help }: ErrorDescription) => {
113
+ export const ErrorFeedback = (
114
+ { code = 0, message, debug, title, body, image, action, description, help, direction = 'row', style, className }: ErrorDescription,
115
+ ) => {
68
116
  const t = useTranslate(dictionary) as Record<string, string>
69
117
  const [showDetails, setShowDetails] = useState(false)
70
118
  const shouldShowButtons = !!(action || (debug && message))
71
119
 
72
120
  function renderBody() {
73
- return typeof body === 'string' ? <Text appearance="body1" mt={5} colorScheme="inverse">{body}</Text> : body
121
+ return typeof body === 'string' ? <Text appearance="body1" colorScheme="inverse" className="description">{body}</Text> : body
74
122
  }
75
123
 
76
124
  return (
77
- <Box bg="light.400">
78
- <Container>
79
- <Flex alignItems="center" sx={{ padding: 12 }}>
80
- <Box width={5} sx={{ display: ['block', 'none'] }}>
81
- <Flex justifyContent="flex-end" pr={20}>
82
- {image ?? imageMap[code] ?? <ServerError style={imageStyle} />}
83
- </Flex>
84
- </Box>
85
- <Box width={[7, 12]}>
86
- <LinkBox href="/">
87
- <Logo style={{ width: '130px', height: '30px' }} />
88
- </LinkBox>
89
- <Box w={[7, 12]}>
90
- <Text appearance="h4" mt={5} colorScheme="inverse">
91
- {(code && !title) ? `${code}. ` : ''}
92
- <Text appearance="h4" as="span" colorScheme="light.700">
93
- {title ?? t[`${code}.title`]}
94
- </Text>
125
+ <FeedbackBox style={style} className={className}>
126
+ <div className={listToClass(['content', direction])}>
127
+ <div className="image">{image ?? imageMap[code] ?? <ServerError style={imageStyle} />}</div>
128
+ <div className="text-content">
129
+ <LinkBox href="/" className="logo">
130
+ <Logo style={{ width: '130px', height: '30px' }} />
131
+ </LinkBox>
132
+ <Text appearance="h4" mt={5} colorScheme="inverse" className="title">
133
+ {(code && !title) ? `${code}. ` : ''}
134
+ <Text appearance="h4" as="span" colorScheme="light.700">
135
+ {title ?? t[`${code}.title`]}
136
+ </Text>
137
+ </Text>
138
+ {body ? renderBody() : (
139
+ <>
140
+ <Text appearance="body1" mt={5} colorScheme="inverse" className="description">
141
+ {description ?? t[`${code}.description`]}
95
142
  </Text>
96
143
 
97
- {body ? renderBody() : (
98
- <>
99
- <Text appearance="body1" mt={5} colorScheme="inverse">
100
- {description ?? t[`${code}.description`]}
101
- </Text>
102
-
103
- <Text appearance="body1" colorScheme="light.700" mt={1}>
104
- {help ?? t[`${code}.help`]}
105
- </Text>
106
- </>
107
- )}
108
-
109
- {shouldShowButtons && (
110
- <Flex flexDirection="row" style={{ gap: '10px', marginTop: '12px' }}>
111
- {action && (
112
- <Button colorScheme="inverse" onClick={action.onClick}>
113
- {action.label}
114
- </Button>
115
- )}
116
- {debug && message && (
117
- <Button appearance="outlined" colorScheme="inverse" onClick={() => setShowDetails(v => !v)}>
118
- {showDetails ? t.hideDetails : t.showDetails}
119
- </Button>
120
- )}
121
- </Flex>
144
+ <Text appearance="body1" colorScheme="light.700" className="help">
145
+ {help ?? t[`${code}.help`]}
146
+ </Text>
147
+ </>
148
+ )}
149
+ {shouldShowButtons && (
150
+ <div className="buttons">
151
+ {action && (
152
+ <Button colorScheme="inverse" onClick={action.onClick}>
153
+ {action.label}
154
+ </Button>
122
155
  )}
123
- {showDetails && (
124
- <Box bg="danger" mt={8} p={4} sx={{ borderRadius: '5px' }}>
125
- <Text appearance="microtext1" colorScheme="danger.contrastText">{message}</Text>
126
- </Box>
156
+ {debug && message && (
157
+ <Button appearance="outlined" colorScheme="inverse" onClick={() => setShowDetails(v => !v)}>
158
+ {showDetails ? t.hideDetails : t.showDetails}
159
+ </Button>
127
160
  )}
128
- </Box>
129
- </Box>
130
- </Flex>
131
- </Container>
132
- </Box>
161
+ </div>
162
+ )}
163
+ {showDetails && (
164
+ <div className="details">
165
+ <Text appearance="microtext1">{message}</Text>
166
+ </div>
167
+ )}
168
+ </div>
169
+ </div>
170
+ </FeedbackBox>
133
171
  )
134
172
  }
135
173
 
@@ -0,0 +1,69 @@
1
+ import { Box, IconBox, Input } from '@citric/core'
2
+ import { Filter, Times } from '@citric/icons'
3
+ import { FieldAddon, FieldGroup, IconButton } from '@citric/ui'
4
+ import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
5
+ import { debounce } from 'lodash'
6
+ import { useCallback, useState } from 'react'
7
+
8
+ export const SearchInput = ({
9
+ searchText,
10
+ defaultValue,
11
+ disabled = false,
12
+ onChange,
13
+ style,
14
+ className,
15
+ }: {
16
+ searchText: string,
17
+ defaultValue?: string,
18
+ disabled?: boolean,
19
+ onChange: (value?: string) => void,
20
+ style?: React.CSSProperties,
21
+ className?: string,
22
+ }) => {
23
+ const [value, setValue] = useState(defaultValue)
24
+ const runOnChange = useCallback(debounce(onChange, 800), [onChange])
25
+ const t = useTranslate(dictionary)
26
+
27
+ return (
28
+ <Box sx={{ position: 'relative' }} style={style} className={className}>
29
+ <FieldGroup>
30
+ <FieldAddon>
31
+ <IconBox size="xs" colorIcon="light.700">
32
+ <Filter />
33
+ </IconBox>
34
+ </FieldAddon>
35
+ <Input
36
+ value={value}
37
+ placeholder={searchText}
38
+ onChange={(e) => {
39
+ setValue(e.target.value)
40
+ runOnChange(e.target.value)
41
+ }}
42
+ disabled={disabled}
43
+ maxLength={255}
44
+ />
45
+ </FieldGroup>
46
+ {!!value && (
47
+ <IconButton
48
+ sx={{ position: 'absolute', right: '20px', top: '50%', transform: 'translate(50%, -50%)' }}
49
+ onClick={() => {
50
+ setValue('')
51
+ runOnChange('')
52
+ }}
53
+ aria-label={t.ariaClearField}
54
+ >
55
+ <Times />
56
+ </IconButton>
57
+ )}
58
+ </Box>
59
+ )
60
+ }
61
+
62
+ const dictionary = {
63
+ en: {
64
+ ariaClearField: 'Clear field',
65
+ },
66
+ pt: {
67
+ ariaClearField: 'Limpar campo',
68
+ },
69
+ } satisfies Dictionary
@@ -0,0 +1,264 @@
1
+ import { IconBox, Text } from '@citric/core'
2
+ import { ChevronDown } from '@citric/icons'
3
+ import { listToClass, theme } from '@stack-spot/portal-theme'
4
+ import { InputHTMLAttributes, useCallback, useEffect, useMemo, useRef, useState } from 'react'
5
+ import { styled } from 'styled-components'
6
+
7
+ interface BaseSelectProps<T> extends Omit<InputHTMLAttributes<HTMLSelectElement>, 'value' | 'onChange'> {
8
+ /**
9
+ * The current value.
10
+ */
11
+ value: T | undefined,
12
+ /**
13
+ * The label for the empty option. This sets the value to undefined.
14
+ *
15
+ * If this is not set, there won't be an empty option for this select.
16
+ */
17
+ emptyOption?: string,
18
+ /**
19
+ * The options to render in this selection menu.
20
+ */
21
+ options: T[],
22
+ /**
23
+ * Provides the value of each option. This can be either a key of the option object or a function that receives the option and returns
24
+ * the value.
25
+ *
26
+ * This is required if the options are not strings or numbers.
27
+ *
28
+ * @example
29
+ * - `'id'`
30
+ * - `(option) => option.id`
31
+ */
32
+ renderValue?: keyof T | ((item: Exclude<T, undefined>) => string),
33
+ /**
34
+ * Provides the label of each option. This can be either a key of the option object or a function that receives the option and returns
35
+ * the label.
36
+ *
37
+ * This is required if the options are not strings or numbers.
38
+ *
39
+ * @example
40
+ * - `'name'`
41
+ * - `(option) => option.name`
42
+ */
43
+ renderLabel?: keyof T | ((item: Exclude<T, undefined>) => string),
44
+ /**
45
+ * Called when the value changes.
46
+ * @param value the new value.
47
+ */
48
+ onChange: (value: T | undefined) => void,
49
+ /**
50
+ * The maximum number of items before showing a vertical scroll bar.
51
+ * @default 6
52
+ */
53
+ maxItems?: number,
54
+ }
55
+
56
+ interface OptionalSelectProps<T> extends BaseSelectProps<T> {
57
+ emptyOption: string,
58
+ }
59
+
60
+ interface RequiredSelectProps<T> extends Omit<BaseSelectProps<T>, 'onChange'> {
61
+ emptyOption?: undefined,
62
+ value: T,
63
+ onChange: (value: T) => void,
64
+ }
65
+
66
+ type SelectProps<T> = OptionalSelectProps<T> | RequiredSelectProps<T>
67
+
68
+ const OPTION_HEIGHT = 32
69
+ const LIST_BOTTOM_PADDING = 7
70
+
71
+ const SelectBox = styled.div<{ $maxItems: number }>`
72
+ position: relative;
73
+
74
+ select {
75
+ border: none;
76
+ height: 40px;
77
+ opacity: 0;
78
+ pointer-events: none;
79
+ }
80
+
81
+ .fake-select {
82
+ position: absolute;
83
+ top: 0;
84
+ left: 0;
85
+ right: 0;
86
+ border-radius: 0.25rem;
87
+ display: flex;
88
+ flex-direction: column;
89
+ border: 1px solid ${theme.color.light[600]};
90
+ transition: border-color 0.3s, box-shadow 0.3s;
91
+ z-index: 1;
92
+ background-color: ${theme.color.light[300]};
93
+
94
+ .arrow {
95
+ transition: transform ease-in-out 0.3s;
96
+ }
97
+
98
+ &.focused, &.open {
99
+ border: 1px solid ${theme.color.primary[500]};
100
+ box-shadow: 0 0 0 1px ${theme.color.primary[500]};
101
+ }
102
+
103
+ &.open {
104
+ .arrow {
105
+ transform: rotate(180deg);
106
+ }
107
+ .options {
108
+ /* lets the overflow be hidden until the animation on the height ends. */
109
+ overflow-y: auto;
110
+ animation: 0.3s overflow-animation;
111
+ @keyframes overflow-animation {
112
+ 0% {
113
+ overflow-y: hidden;
114
+ }
115
+ 99% {
116
+ overflow-y: hidden;
117
+ }
118
+ 100% {
119
+ overflow-y: auto;
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ .current-value {
126
+ height: 40px;
127
+ display: flex;
128
+ flex-direction: row;
129
+ padding: 0 8px;
130
+ justify-content: space-between;
131
+ align-items: center;
132
+ cursor: pointer;
133
+ }
134
+
135
+ .clipped-text {
136
+ text-overflow: ellipsis;
137
+ width: 100%;
138
+ overflow: hidden;
139
+ white-space: nowrap;
140
+ }
141
+
142
+ .options {
143
+ list-style: none;
144
+ padding: 0;
145
+ margin: 0;
146
+ overflow-y: hidden;
147
+ transition: height ease-in-out 0.3s;
148
+ max-height: ${({ $maxItems }) => $maxItems * OPTION_HEIGHT + LIST_BOTTOM_PADDING}px;
149
+
150
+ li {
151
+ height: ${OPTION_HEIGHT}px;
152
+ display: flex;
153
+ flex-direction: row;
154
+ align-items: center;
155
+ padding: 0 8px;
156
+ border-top: 1px solid ${theme.color.light[600]};
157
+ cursor: pointer;
158
+ transition: background-color 0.2s;
159
+ &:hover {
160
+ background-color: ${theme.color.light[500]};
161
+ }
162
+ }
163
+ }
164
+ }
165
+ `
166
+
167
+ function renderProperty(option: any, renderer: any): string {
168
+ if (!renderer) return `${option ?? ''}`
169
+ return typeof renderer === 'function' ? renderer(option) : (option[renderer] ?? `${option ?? ''}`)
170
+ }
171
+
172
+ const FakeOption = (
173
+ { value, label, onChange }: { value: string, label: string, onChange: (event: { target: { value: string } }) => void },
174
+ ) => (
175
+ <li className="option" onClick={() => onChange({ target: { value } })}>
176
+ <Text className="clipped-text">{label}</Text>
177
+ </li>
178
+ )
179
+
180
+ /**
181
+ * Renders a Select component using the Citric Design System.
182
+ *
183
+ * The styled version of the select component is rendered on top of the default select from the browser. Visual users will use the Citric
184
+ * version of a Select, but blind users, who interacts with the keyboard, will use the default browser select instead, which is already
185
+ * highly optimized for accessibility.
186
+ * @param props the component props: {@link SelectProps}.
187
+ */
188
+ export function Select<T>({
189
+ onChange, options, value, emptyOption, renderLabel, renderValue, maxItems = 6, onFocus, onBlur, style, className, ...props
190
+ }: SelectProps<T>) {
191
+ const [open, setOpen] = useState(false)
192
+ const [focused, setFocused] = useState(false)
193
+ const valueLabelRef = useRef<HTMLDivElement>(null)
194
+
195
+ const onChangeOption = useCallback((event: { target: { value: string } }) => {
196
+ const value = options.find(o => renderProperty(o, renderValue) === event.target.value)
197
+ onChange(value!)
198
+ }, [])
199
+
200
+ const onClickOutside = useCallback((event: MouseEvent) => {
201
+ if (valueLabelRef.current && !valueLabelRef.current.contains(event.target as Node)) setOpen(false)
202
+ }, [])
203
+
204
+ const [htmlOptions, fakeOptions] = useMemo(
205
+ () => options.reduce<[React.ReactElement[], React.ReactElement[]]>(([opts, fake], o) => {
206
+ const id = renderProperty(o, renderValue)
207
+ const label = renderProperty(o, renderLabel)
208
+ return [
209
+ [...opts, <option key={id} value={id} selected={value === id}>{label}</option>],
210
+ [...fake, <FakeOption key={id} value={id} label={label} onChange={onChangeOption} />],
211
+ ]
212
+ }, [[], []]),
213
+ [options, value],
214
+ )
215
+
216
+ const height = open ? (LIST_BOTTOM_PADDING + (options.length + (emptyOption === undefined ? 0 : 1)) * OPTION_HEIGHT) : 0
217
+
218
+ function getCurrentValue() {
219
+ return value === undefined ? '' : renderProperty(value, renderValue)
220
+ }
221
+
222
+ function getCurrentLabel() {
223
+ return value === undefined ? (emptyOption ?? '') : renderProperty(value, renderLabel)
224
+ }
225
+
226
+ useEffect(() => {
227
+ const detach = () => document.removeEventListener('mousedown', onClickOutside)
228
+ if (open) document.addEventListener('mousedown', onClickOutside)
229
+ else detach()
230
+ return detach
231
+ }, [open])
232
+
233
+ return (
234
+ <SelectBox style={style} className={className} $maxItems={maxItems}>
235
+ { /* Screen readers can use the select component from the browser instead of the highly styled component we show. */ }
236
+ <select
237
+ {...props}
238
+ value={getCurrentValue()}
239
+ onChange={onChangeOption}
240
+ onFocus={(ev) => {
241
+ setFocused(true)
242
+ onFocus?.(ev)
243
+ }}
244
+ onBlur={(ev) => {
245
+ setFocused(false)
246
+ onBlur?.(ev)
247
+ }}
248
+ >
249
+ {emptyOption === undefined ? null : <option value="" selected={!value}>{emptyOption}</option>}
250
+ {htmlOptions}
251
+ </select>
252
+ <div className={listToClass(['fake-select', open && 'open', focused && 'focused'])} aria-hidden>
253
+ <div ref={valueLabelRef} className="current-value" onClick={() => setOpen(!open)}>
254
+ <Text className="clipped-text">{getCurrentLabel()}</Text>
255
+ <IconBox className="arrow"><ChevronDown /></IconBox>
256
+ </div>
257
+ <ul className="options" style={{ height: `${height}px` }}>
258
+ {emptyOption === undefined ? null : <FakeOption value="" label={emptyOption} onChange={onChangeOption} />}
259
+ {fakeOptions}
260
+ </ul>
261
+ </div>
262
+ </SelectBox>
263
+ )
264
+ }
@@ -16,18 +16,25 @@ interface Props {
16
16
  hasUnreadNotification?: boolean,
17
17
  }
18
18
 
19
- const NotificationsComponent = styled(Flex) <{ $scroll?: boolean }>`
19
+ const ANIMATION_DURATION_MS = 300
20
+ const MAX_HEIGHT_TRANSITION = `max-height ease-in ${ANIMATION_DURATION_MS / 1000}s`
21
+
22
+ const NotificationsComponent = styled.div<{ $scroll?: boolean }>`
20
23
  max-height: 0;
21
24
  z-index: 2;
22
25
  visibility: hidden;
23
26
  position: absolute;
24
27
  top: calc(var(--header-height) + 4px);
25
28
  right: -270%;
29
+ opacity: 0;
26
30
  width: 400px;
31
+ transition: ${MAX_HEIGHT_TRANSITION}, opacity ${ANIMATION_DURATION_MS}ms ease-in-out, visibility 0s ${ANIMATION_DURATION_MS}ms;
27
32
 
28
33
  &.visible {
29
34
  visibility: visible;
30
35
  min-height: 400px;
36
+ opacity: 1;
37
+ transition: ${MAX_HEIGHT_TRANSITION}, opacity ${ANIMATION_DURATION_MS}ms ease-in-out;
31
38
  }
32
39
 
33
40
  .content {
@@ -35,7 +42,7 @@ const NotificationsComponent = styled(Flex) <{ $scroll?: boolean }>`
35
42
  border: 1px solid ${theme.color.light[400]};
36
43
  box-shadow: 4px 4px 48px ${theme.color.danger.contrastText};
37
44
  background-color: ${theme.color.light[300]};
38
- overflow-y: ${({ $scroll }) => $scroll ? 'auto' : 'hidden'};
45
+ overflow-y: ${({ $scroll }) => ($scroll ? 'auto' : 'hidden')};
39
46
  overflow-x: hidden;
40
47
  }
41
48
 
@@ -160,11 +167,13 @@ interface Props {
160
167
  isSummary: boolean,
161
168
  }
162
169
 
170
+ // fixme: remove this component in the next major
163
171
  /**
164
172
  * NotificationComponent component that renders the notifications panel.
165
- * It render the notification icon and when clicked the notification modal is opened.
173
+ * It renders the notification icon and when clicked the notification modal is opened.
166
174
  *
167
175
  * @param props the component's props {@link Props}.
176
+ * @deprecated this functionality has been moved to the Layout library. This is now a property of the Header.
168
177
  */
169
178
  export const NotificationComponent = ({
170
179
  hasUnreadNotification, onMarkAsReadUnread, notifications, isLoading, error,
@@ -225,7 +234,7 @@ export const NotificationComponent = ({
225
234
  {visible && <Overlay onClick={() => setVisible(false)} />}
226
235
 
227
236
  <NotificationsComponent
228
- className={listToClass(['notification-component', visible ? 'visible' : undefined])}
237
+ className={listToClass(['notification-list', visible ? 'visible' : undefined])}
229
238
  $scroll={true}
230
239
  aria-hidden={!visible}
231
240
  >
@@ -243,7 +252,6 @@ export const NotificationComponent = ({
243
252
  </Flex>
244
253
 
245
254
  <NotificationsFilter type={type} onChangeFilterType={updateType} />
246
-
247
255
  <AsyncContent error={error} errorDetails={errorDetails} loading={isLoading}>
248
256
  {notifications?.length ? <StyledBox>
249
257
  <ScrollView id="scrollableNotifications" direction="vertical" style={{ maxHeight: 'calc(100vh - 300px)' }}>