cozy-ui 146.2.1 → 146.3.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 (66) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/package.json +1 -1
  3. package/react/DropdownText/index.jsx +3 -5
  4. package/react/MemberAutocomplete/MemberAutocomplete.spec.jsx +83 -0
  5. package/react/MemberAutocomplete/MemberAutocompleteActions.jsx +77 -0
  6. package/react/MemberAutocomplete/MemberChip.jsx +29 -0
  7. package/react/MemberAutocomplete/MemberSuggestion.jsx +32 -0
  8. package/react/MemberAutocomplete/Readme.md +65 -0
  9. package/react/MemberAutocomplete/helpers.js +70 -0
  10. package/react/MemberAutocomplete/helpers.spec.js +51 -0
  11. package/react/MemberAutocomplete/index.jsx +247 -0
  12. package/react/MemberAutocomplete/locales/de.json +5 -0
  13. package/react/MemberAutocomplete/locales/en.json +5 -0
  14. package/react/MemberAutocomplete/locales/es.json +5 -0
  15. package/react/MemberAutocomplete/locales/fr.json +5 -0
  16. package/react/MemberAutocomplete/locales/index.js +17 -0
  17. package/react/MemberAutocomplete/locales/it.json +5 -0
  18. package/react/MemberAutocomplete/locales/ru.json +5 -0
  19. package/react/MemberAutocomplete/locales/vi.json +5 -0
  20. package/react/MemberAutocomplete/useMemberInput.js +80 -0
  21. package/react/MemberAutocomplete/useMemberSuggestions.js +65 -0
  22. package/react/MemberAutocomplete/useMemberSuggestions.spec.js +97 -0
  23. package/react/MemberAutocomplete/useRoleMenuFocus.js +35 -0
  24. package/react/MemberRow/MemberOrGroupAvatar.jsx +21 -0
  25. package/react/MemberRow/MemberRoleMenu.jsx +29 -4
  26. package/react/MemberRow/MemberRoleMenuHeader.jsx +3 -8
  27. package/react/MemberRow/Readme.md +5 -4
  28. package/react/MemberRow/index.jsx +3 -19
  29. package/react/MemberRow/propTypes.js +1 -0
  30. package/react/MuiCozyTheme/overrides/makeDarkOverrides.js +5 -0
  31. package/transpiled/react/DropdownText/index.js +3 -3
  32. package/transpiled/react/MemberAutocomplete/MemberAutocomplete.spec.d.ts +1 -0
  33. package/transpiled/react/MemberAutocomplete/MemberAutocompleteActions.d.ts +25 -0
  34. package/transpiled/react/MemberAutocomplete/MemberAutocompleteActions.js +69 -0
  35. package/transpiled/react/MemberAutocomplete/MemberChip.d.ts +39 -0
  36. package/transpiled/react/MemberAutocomplete/MemberChip.js +33 -0
  37. package/transpiled/react/MemberAutocomplete/MemberSuggestion.d.ts +36 -0
  38. package/transpiled/react/MemberAutocomplete/MemberSuggestion.js +31 -0
  39. package/transpiled/react/MemberAutocomplete/helpers.d.ts +7 -0
  40. package/transpiled/react/MemberAutocomplete/helpers.js +73 -0
  41. package/transpiled/react/MemberAutocomplete/helpers.spec.d.ts +1 -0
  42. package/transpiled/react/MemberAutocomplete/index.d.ts +71 -0
  43. package/transpiled/react/MemberAutocomplete/index.js +295 -0
  44. package/transpiled/react/MemberAutocomplete/locales/index.d.ts +16 -0
  45. package/transpiled/react/MemberAutocomplete/locales/index.js +44 -0
  46. package/transpiled/react/MemberAutocomplete/useMemberInput.d.ts +14 -0
  47. package/transpiled/react/MemberAutocomplete/useMemberInput.js +76 -0
  48. package/transpiled/react/MemberAutocomplete/useMemberSuggestions.d.ts +11 -0
  49. package/transpiled/react/MemberAutocomplete/useMemberSuggestions.js +120 -0
  50. package/transpiled/react/MemberAutocomplete/useMemberSuggestions.spec.d.ts +1 -0
  51. package/transpiled/react/MemberAutocomplete/useRoleMenuFocus.d.ts +6 -0
  52. package/transpiled/react/MemberAutocomplete/useRoleMenuFocus.js +37 -0
  53. package/transpiled/react/MemberList/index.d.ts +1 -0
  54. package/transpiled/react/MemberRow/MemberOrGroupAvatar.d.ts +39 -0
  55. package/transpiled/react/MemberRow/MemberOrGroupAvatar.js +29 -0
  56. package/transpiled/react/MemberRow/MemberRoleMenu.d.ts +9 -1
  57. package/transpiled/react/MemberRow/MemberRoleMenu.js +26 -9
  58. package/transpiled/react/MemberRow/MemberRoleMenuHeader.d.ts +1 -0
  59. package/transpiled/react/MemberRow/MemberRoleMenuHeader.js +4 -13
  60. package/transpiled/react/MemberRow/MemberRowActions.d.ts +1 -0
  61. package/transpiled/react/MemberRow/index.d.ts +1 -10
  62. package/transpiled/react/MemberRow/index.js +4 -13
  63. package/transpiled/react/MemberRow/propTypes.d.ts +1 -0
  64. package/transpiled/react/MemberRow/propTypes.js +1 -0
  65. package/transpiled/react/MuiCozyTheme/overrides/makeDarkOverrides.d.ts +5 -0
  66. package/transpiled/react/MuiCozyTheme/overrides/makeDarkOverrides.js +5 -0
@@ -0,0 +1,247 @@
1
+ import cx from 'classnames'
2
+ import PropTypes from 'prop-types'
3
+ import React, { useState } from 'react'
4
+ import { useI18n, useExtendI18n } from 'twake-i18n'
5
+
6
+ import MemberAutocompleteActions from './MemberAutocompleteActions'
7
+ import MemberChip from './MemberChip'
8
+ import MemberSuggestion from './MemberSuggestion'
9
+ import { getUnpickedMembers, isEmailLike } from './helpers'
10
+ import { locales } from './locales'
11
+ import { useMemberInput } from './useMemberInput'
12
+ import { useMemberSuggestions } from './useMemberSuggestions'
13
+ import { useRoleMenuFocus } from './useRoleMenuFocus'
14
+ import Autocomplete from '../Autocomplete'
15
+ import Buttons from '../Buttons'
16
+ import { memberPropType, rolesPropType } from '../MemberRow/propTypes'
17
+ import TextField from '../TextField'
18
+ import Typography from '../Typography'
19
+ import { getRandomUUID } from '../helpers/getRandomUUID'
20
+ import { makeStyles } from '../styles'
21
+
22
+ const LIMIT_TAGS = 3
23
+
24
+ const useStyles = makeStyles({
25
+ inputRoot: {
26
+ '&&': {
27
+ padding: '.375rem .5625rem',
28
+ paddingRight: ({ actionsWidth }) =>
29
+ actionsWidth > 0 ? `calc(${actionsWidth}px + 1.5rem)` : null
30
+ }
31
+ },
32
+ input: {
33
+ '&&': {
34
+ padding: '.59375rem .25rem'
35
+ },
36
+ '&&:first-child': {
37
+ paddingLeft: '.4375rem'
38
+ }
39
+ },
40
+ submit: {
41
+ marginTop: '.3125rem'
42
+ },
43
+ listbox: {
44
+ maxHeight: '9rem'
45
+ },
46
+ option: {
47
+ '&[aria-selected="true"]': {
48
+ backgroundColor: 'transparent'
49
+ },
50
+ '&[data-focus="true"], &:active': {
51
+ backgroundColor: 'var(--actionColorSelected)'
52
+ }
53
+ }
54
+ })
55
+
56
+ /**
57
+ * Field to pick the members to invite among suggestions, shown as chips
58
+ */
59
+ const MemberAutocomplete = ({
60
+ value,
61
+ getSuggestions = () => [],
62
+ fetchSuggestions,
63
+ getMembersFromText = () => [],
64
+ minQueryLength = 3,
65
+ debounceMs = 300,
66
+ roles,
67
+ role,
68
+ title,
69
+ placeholder,
70
+ loading = false,
71
+ disabled = false,
72
+ autoFocus = false,
73
+ onChange,
74
+ onRoleChange,
75
+ onSubmit
76
+ }) => {
77
+ useExtendI18n(locales)
78
+ const { t } = useI18n()
79
+ const [inputId] = useState(getRandomUUID)
80
+ const [inputValue, setInputValue] = useState('')
81
+ const [actionsWidth, setActionsWidth] = useState(0)
82
+ const classes = useStyles({ actionsWidth })
83
+ const { suggestions, isFetching } = useMemberSuggestions({
84
+ inputValue,
85
+ getSuggestions,
86
+ fetchSuggestions,
87
+ getMembersFromText,
88
+ minQueryLength,
89
+ debounceMs
90
+ })
91
+ const {
92
+ handleChange,
93
+ handleInputChange,
94
+ handleKeyDown,
95
+ handlePaste,
96
+ handleBlur
97
+ } = useMemberInput({
98
+ inputValue,
99
+ value,
100
+ suggestions,
101
+ getMembersFromText,
102
+ onInputValueChange: setInputValue,
103
+ onChange
104
+ })
105
+ const {
106
+ isFocusKept,
107
+ handleRoleMenuOpen,
108
+ handleRoleMenuClose,
109
+ handleInputFocus
110
+ } = useRoleMenuFocus(inputId)
111
+
112
+ const titleId = `${inputId}-title`
113
+ const isTitleShown = title !== undefined
114
+ const isSubmitShown = onSubmit !== undefined
115
+ const hasMembersToSend = value.length > 0 || isEmailLike(inputValue)
116
+ const isLoading = loading || isFetching
117
+ const unpickedSuggestions = getUnpickedMembers(suggestions, value)
118
+ const fieldPlaceholder = value.length === 0 ? placeholder : undefined
119
+
120
+ return (
121
+ <div>
122
+ {isTitleShown && (
123
+ <Typography id={titleId} variant="h5" className="u-mb-half">
124
+ {title}
125
+ </Typography>
126
+ )}
127
+ <div className="u-flex u-flex-items-start">
128
+ <Autocomplete
129
+ className="u-flex-grow-1"
130
+ classes={{
131
+ inputRoot: classes.inputRoot,
132
+ input: classes.input,
133
+ paper: 'u-mv-half u-bdrs-4',
134
+ listbox: cx('u-pv-half', classes.listbox),
135
+ option: cx('u-p-0 u-mih-auto', classes.option)
136
+ }}
137
+ id={inputId}
138
+ multiple
139
+ freeSolo
140
+ autoHighlight
141
+ disableClearable
142
+ limitTags={isFocusKept ? -1 : LIMIT_TAGS}
143
+ disabled={disabled}
144
+ options={unpickedSuggestions}
145
+ value={value}
146
+ inputValue={inputValue}
147
+ filterOptions={allOptions => allOptions}
148
+ getOptionLabel={option => option.name ?? option}
149
+ getOptionSelected={(option, picked) => option.id === picked.id}
150
+ renderOption={suggestion => (
151
+ <MemberSuggestion suggestion={suggestion} />
152
+ )}
153
+ renderTags={(members, getTagProps) =>
154
+ members.map((member, index) => (
155
+ <MemberChip
156
+ key={member.id}
157
+ member={member}
158
+ tagProps={getTagProps({ index })}
159
+ />
160
+ ))
161
+ }
162
+ renderInput={params => (
163
+ <TextField
164
+ {...params}
165
+ variant="outlined"
166
+ type="email"
167
+ placeholder={fieldPlaceholder}
168
+ autoFocus={autoFocus}
169
+ InputProps={{
170
+ ...params.InputProps,
171
+ endAdornment: (
172
+ <MemberAutocompleteActions
173
+ roles={roles}
174
+ role={role}
175
+ isLoading={isLoading}
176
+ disabled={disabled}
177
+ onRoleChange={onRoleChange}
178
+ onRoleMenuOpen={handleRoleMenuOpen}
179
+ onRoleMenuClose={handleRoleMenuClose}
180
+ onWidthChange={setActionsWidth}
181
+ />
182
+ )
183
+ }}
184
+ inputProps={{
185
+ ...params.inputProps,
186
+ 'aria-labelledby': isTitleShown ? titleId : undefined,
187
+ onKeyDown: handleKeyDown,
188
+ onPaste: handlePaste
189
+ }}
190
+ focused={isFocusKept || undefined}
191
+ onFocus={handleInputFocus}
192
+ onBlur={handleBlur}
193
+ />
194
+ )}
195
+ onChange={handleChange}
196
+ onInputChange={handleInputChange}
197
+ />
198
+ {isSubmitShown && (
199
+ <Buttons
200
+ className={cx('u-ml-1', classes.submit)}
201
+ label={t('MemberAutocomplete.send')}
202
+ disabled={disabled || !hasMembersToSend}
203
+ onClick={onSubmit}
204
+ />
205
+ )}
206
+ </div>
207
+ </div>
208
+ )
209
+ }
210
+
211
+ MemberAutocomplete.propTypes = {
212
+ /** Picked members, see `MemberRow` */
213
+ value: PropTypes.arrayOf(memberPropType).isRequired,
214
+ /** Returns the local members matching a text */
215
+ getSuggestions: PropTypes.func,
216
+ /** Resolves with the remote members matching a text, shown before the local ones */
217
+ fetchSuggestions: PropTypes.func,
218
+ /** Turns a typed or pasted text (e.g. emails) into members, suggested when nothing matches.
219
+ * Give them the `email` of the person, so that a suggested or picked one replaces them */
220
+ getMembersFromText: PropTypes.func,
221
+ /** Text length from which `fetchSuggestions` is called */
222
+ minQueryLength: PropTypes.number,
223
+ /** Delay before calling `fetchSuggestions` */
224
+ debounceMs: PropTypes.number,
225
+ /** Roles the members can get, as `{ value, label }` */
226
+ roles: rolesPropType,
227
+ /** Role given to the picked members */
228
+ role: PropTypes.string,
229
+ /** Title shown above the field */
230
+ title: PropTypes.string,
231
+ /** Placeholder of the field, shown while no member is picked */
232
+ placeholder: PropTypes.string,
233
+ /** Whether the suggestions are loading */
234
+ loading: PropTypes.bool,
235
+ /** Whether the field is disabled */
236
+ disabled: PropTypes.bool,
237
+ /** Whether the field takes the focus */
238
+ autoFocus: PropTypes.bool,
239
+ /** Called with the picked members */
240
+ onChange: PropTypes.func.isRequired,
241
+ /** Called with the new role */
242
+ onRoleChange: PropTypes.func,
243
+ /** Shows a send button calling it */
244
+ onSubmit: PropTypes.func
245
+ }
246
+
247
+ export default MemberAutocomplete
@@ -0,0 +1,5 @@
1
+ {
2
+ "MemberAutocomplete": {
3
+ "send": "Senden"
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "MemberAutocomplete": {
3
+ "send": "Send"
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "MemberAutocomplete": {
3
+ "send": "Enviar"
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "MemberAutocomplete": {
3
+ "send": "Envoyer"
4
+ }
5
+ }
@@ -0,0 +1,17 @@
1
+ import de from './de.json'
2
+ import en from './en.json'
3
+ import es from './es.json'
4
+ import fr from './fr.json'
5
+ import it from './it.json'
6
+ import ru from './ru.json'
7
+ import vi from './vi.json'
8
+
9
+ export const locales = {
10
+ de,
11
+ en,
12
+ es,
13
+ fr,
14
+ it,
15
+ ru,
16
+ vi
17
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "MemberAutocomplete": {
3
+ "send": "Invia"
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "MemberAutocomplete": {
3
+ "send": "Отправить"
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "MemberAutocomplete": {
3
+ "send": "Gửi"
4
+ }
5
+ }
@@ -0,0 +1,80 @@
1
+ import {
2
+ getUnpickedMembers,
3
+ isEmailLike,
4
+ makeMembersFromValue,
5
+ resolveKnownMembers
6
+ } from './helpers'
7
+
8
+ /**
9
+ * Turns the typed text into members on Enter, Space, paste and blur. A text giving no member
10
+ * stays typed, and a member for a suggested or picked person is replaced by it.
11
+ *
12
+ * @param {object} params
13
+ * @param {string} params.inputValue - Typed text
14
+ * @param {object[]} params.value - Picked members
15
+ * @param {object[]} params.suggestions - Suggested members
16
+ * @param {Function} params.getMembersFromText - Turns a text into members
17
+ * @param {Function} params.onInputValueChange - Called with the typed text
18
+ * @param {Function} params.onChange - Called with the picked members
19
+ */
20
+ export const useMemberInput = ({
21
+ inputValue,
22
+ value,
23
+ suggestions,
24
+ getMembersFromText,
25
+ onInputValueChange,
26
+ onChange
27
+ }) => {
28
+ const getKnownMembersFromText = text =>
29
+ resolveKnownMembers(getMembersFromText(text), [...value, ...suggestions])
30
+
31
+ const addMembersFromText = text => {
32
+ const members = getKnownMembersFromText(text)
33
+ if (members.length === 0) return false
34
+
35
+ onChange([...value, ...getUnpickedMembers(members, value)])
36
+ return true
37
+ }
38
+
39
+ const handleChange = (event, newValue, reason) => {
40
+ const typedText = newValue.find(item => typeof item === 'string')
41
+ const isUnknownText =
42
+ typedText !== undefined && getKnownMembersFromText(typedText).length === 0
43
+ if (isUnknownText) return
44
+
45
+ onChange(makeMembersFromValue(newValue, getKnownMembersFromText))
46
+ if (reason !== 'remove-option') onInputValueChange('')
47
+ }
48
+
49
+ // The Autocomplete resets the text whenever `value` is a new array, even with the same members
50
+ const handleInputChange = (event, newInputValue, reason) => {
51
+ if (reason !== 'reset') onInputValueChange(newInputValue)
52
+ }
53
+
54
+ const handleKeyDown = event => {
55
+ if (event.key === ' ' && addMembersFromText(inputValue)) {
56
+ event.preventDefault()
57
+ onInputValueChange('')
58
+ }
59
+ }
60
+
61
+ const handlePaste = event => {
62
+ if (addMembersFromText(event.clipboardData.getData('text'))) {
63
+ event.preventDefault()
64
+ }
65
+ }
66
+
67
+ const handleBlur = () => {
68
+ if (isEmailLike(inputValue) && addMembersFromText(inputValue)) {
69
+ onInputValueChange('')
70
+ }
71
+ }
72
+
73
+ return {
74
+ handleChange,
75
+ handleInputChange,
76
+ handleKeyDown,
77
+ handlePaste,
78
+ handleBlur
79
+ }
80
+ }
@@ -0,0 +1,65 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ import { makeSuggestions } from './helpers'
4
+
5
+ /**
6
+ * Computes the members to suggest for the typed text. The local ones and the ones made
7
+ * from the text are always up to date. The fetched ones come first once `fetchSuggestions`
8
+ * answers for the current text.
9
+ *
10
+ * @param {object} params
11
+ * @param {string} params.inputValue - Typed text
12
+ * @param {Function} params.getSuggestions - Returns the local members matching a text
13
+ * @param {Function} [params.fetchSuggestions] - Resolves with the remote members matching a text
14
+ * @param {Function} params.getMembersFromText - Turns a text into members, suggested when nothing matches
15
+ * @param {number} params.minQueryLength - Text length from which `fetchSuggestions` is called
16
+ * @param {number} params.debounceMs - Delay before calling `fetchSuggestions`
17
+ * @returns {{ suggestions: object[], isFetching: boolean }}
18
+ */
19
+ export const useMemberSuggestions = ({
20
+ inputValue,
21
+ getSuggestions,
22
+ fetchSuggestions,
23
+ getMembersFromText,
24
+ minQueryLength,
25
+ debounceMs
26
+ }) => {
27
+ const [fetched, setFetched] = useState({ query: '', members: [] })
28
+ const fetchSuggestionsRef = useRef()
29
+ fetchSuggestionsRef.current = fetchSuggestions
30
+
31
+ const query = inputValue.trim()
32
+ const isRemoteQuery =
33
+ fetchSuggestions !== undefined && query.length >= minQueryLength
34
+ const isFetching = isRemoteQuery && fetched.query !== query
35
+
36
+ useEffect(() => {
37
+ if (!isRemoteQuery) return
38
+
39
+ let isOutdated = false
40
+ const timeout = setTimeout(async () => {
41
+ let members = []
42
+ try {
43
+ members = (await fetchSuggestionsRef.current(query)) ?? []
44
+ } catch {
45
+ members = []
46
+ }
47
+ if (!isOutdated) setFetched({ query, members })
48
+ }, debounceMs)
49
+
50
+ return () => {
51
+ isOutdated = true
52
+ clearTimeout(timeout)
53
+ }
54
+ }, [query, isRemoteQuery, debounceMs])
55
+
56
+ if (query.length === 0) return { suggestions: [], isFetching: false }
57
+
58
+ const fetchedSuggestions = fetched.query === query ? fetched.members : []
59
+ const suggestions = makeSuggestions(
60
+ [...fetchedSuggestions, ...getSuggestions(query)],
61
+ getMembersFromText(query)
62
+ )
63
+
64
+ return { suggestions, isFetching }
65
+ }
@@ -0,0 +1,97 @@
1
+ import { act, renderHook } from '@testing-library/react-hooks'
2
+
3
+ import { useMemberSuggestions } from './useMemberSuggestions'
4
+
5
+ const alice = { id: 'alice', name: 'Alice' }
6
+ const bob = { id: 'bob', name: 'Bob' }
7
+ const email = { id: 'carol@cozy.cc', name: 'carol@cozy.cc' }
8
+
9
+ describe('useMemberSuggestions', () => {
10
+ beforeEach(() => {
11
+ jest.useFakeTimers()
12
+ })
13
+
14
+ afterEach(() => {
15
+ jest.useRealTimers()
16
+ })
17
+
18
+ const setup = props =>
19
+ renderHook(
20
+ inputValue =>
21
+ useMemberSuggestions({
22
+ inputValue,
23
+ getSuggestions: () => [],
24
+ getMembersFromText: () => [],
25
+ minQueryLength: 3,
26
+ debounceMs: 300,
27
+ ...props
28
+ }),
29
+ { initialProps: 'al' }
30
+ )
31
+
32
+ it('shows the local suggestions at once below the minimum length', () => {
33
+ const fetchSuggestions = jest.fn()
34
+ const { result } = setup({
35
+ getSuggestions: () => [alice],
36
+ fetchSuggestions
37
+ })
38
+
39
+ expect(result.current.suggestions).toEqual([alice])
40
+ expect(fetchSuggestions).not.toHaveBeenCalled()
41
+ })
42
+
43
+ it('shows the local suggestions at once, then the fetched ones first', async () => {
44
+ const { result, rerender } = setup({
45
+ getSuggestions: () => [alice],
46
+ fetchSuggestions: async () => [bob, alice]
47
+ })
48
+
49
+ rerender('ali')
50
+ expect(result.current.suggestions).toEqual([alice])
51
+ expect(result.current.isFetching).toBe(true)
52
+ await act(async () => {
53
+ jest.advanceTimersByTime(300)
54
+ })
55
+
56
+ expect(result.current.suggestions).toEqual([bob, alice])
57
+ expect(result.current.isFetching).toBe(false)
58
+ })
59
+
60
+ it('stops suggesting the fetched members once the text changes', async () => {
61
+ const { result, rerender } = setup({
62
+ fetchSuggestions: async () => [bob],
63
+ getMembersFromText: () => [email]
64
+ })
65
+
66
+ rerender('bob')
67
+ await act(async () => {
68
+ jest.advanceTimersByTime(300)
69
+ })
70
+ rerender('carol@cozy.cc')
71
+
72
+ expect(result.current.suggestions).toEqual([email])
73
+ })
74
+
75
+ it('ignores the answer for an outdated text', async () => {
76
+ const fetchSuggestions = jest.fn(async query =>
77
+ query === 'ali' ? [bob] : []
78
+ )
79
+ const { result, rerender } = setup({ fetchSuggestions })
80
+
81
+ rerender('ali')
82
+ rerender('alic')
83
+ await act(async () => {
84
+ jest.advanceTimersByTime(300)
85
+ })
86
+
87
+ expect(fetchSuggestions).toHaveBeenCalledTimes(1)
88
+ expect(fetchSuggestions).toHaveBeenCalledWith('alic')
89
+ expect(result.current.suggestions).toEqual([])
90
+ })
91
+
92
+ it('suggests the members made from the text when nothing matches', () => {
93
+ const { result } = setup({ getMembersFromText: () => [email] })
94
+
95
+ expect(result.current.suggestions).toEqual([email])
96
+ })
97
+ })
@@ -0,0 +1,35 @@
1
+ import { useState } from 'react'
2
+
3
+ /**
4
+ * Keeps the field shown as focused while the role menu, opened from the focused input,
5
+ * is open. On close, the input gets the focus back if the menu took it.
6
+ *
7
+ * @param {string} inputId - Id of the input
8
+ */
9
+ export const useRoleMenuFocus = inputId => {
10
+ const [isFocusKept, setFocusKept] = useState(false)
11
+
12
+ const handleRoleMenuOpen = () => {
13
+ setFocusKept(document.activeElement.id === inputId)
14
+ }
15
+
16
+ // A menu shown as a bottom sheet on mobile leaves the focus on the input
17
+ const handleRoleMenuClose = () => {
18
+ if (!isFocusKept) return
19
+
20
+ const input = document.getElementById(inputId)
21
+ if (document.activeElement === input) setFocusKept(false)
22
+ else input.focus()
23
+ }
24
+
25
+ const handleInputFocus = () => {
26
+ setFocusKept(false)
27
+ }
28
+
29
+ return {
30
+ isFocusKept,
31
+ handleRoleMenuOpen,
32
+ handleRoleMenuClose,
33
+ handleInputFocus
34
+ }
35
+ }
@@ -0,0 +1,21 @@
1
+ import PropTypes from 'prop-types'
2
+ import React from 'react'
3
+
4
+ import MemberAvatar from './MemberAvatar'
5
+ import MemberGroupAvatar from './MemberGroupAvatar'
6
+ import { memberPropType } from './propTypes'
7
+
8
+ const MemberOrGroupAvatar = ({ member, size }) => {
9
+ const { avatarUrl, initials, color, isGroup } = member
10
+
11
+ if (isGroup) return <MemberGroupAvatar size={size} color={color} />
12
+
13
+ return <MemberAvatar size={size} initials={initials} avatarUrl={avatarUrl} />
14
+ }
15
+
16
+ MemberOrGroupAvatar.propTypes = {
17
+ member: memberPropType.isRequired,
18
+ size: PropTypes.string.isRequired
19
+ }
20
+
21
+ export default MemberOrGroupAvatar
@@ -11,7 +11,16 @@ import ListItemText from '../ListItemText'
11
11
  import Radios from '../Radios'
12
12
  import useBreakpoints from '../providers/Breakpoints'
13
13
 
14
- const MemberRoleMenu = ({ member, value, roles, onChange }) => {
14
+ const MemberRoleMenu = ({
15
+ member,
16
+ value,
17
+ roles,
18
+ size,
19
+ disabled = false,
20
+ onChange,
21
+ onOpen,
22
+ onClose
23
+ }) => {
15
24
  const { isMobile } = useBreakpoints()
16
25
  const buttonRef = useRef()
17
26
  const [isMenuDisplayed, setMenuDisplayed] = useState(false)
@@ -19,6 +28,16 @@ const MemberRoleMenu = ({ member, value, roles, onChange }) => {
19
28
  const label = roles.find(role => role.value === value)?.label
20
29
  const isHeaderShown = isMobile && member !== undefined
21
30
 
31
+ const openMenu = () => {
32
+ setMenuDisplayed(true)
33
+ onOpen?.()
34
+ }
35
+
36
+ const closeMenu = () => {
37
+ setMenuDisplayed(false)
38
+ onClose?.()
39
+ }
40
+
22
41
  const handleRoleClick = role => {
23
42
  if (role.value !== value) onChange(role.value)
24
43
  }
@@ -29,8 +48,10 @@ const MemberRoleMenu = ({ member, value, roles, onChange }) => {
29
48
  ref={buttonRef}
30
49
  aria-controls="simple-menu"
31
50
  aria-haspopup="true"
32
- onClick={() => setMenuDisplayed(true)}
51
+ onClick={openMenu}
33
52
  textVariant="body2"
53
+ size={size}
54
+ disabled={disabled}
34
55
  >
35
56
  {label}
36
57
  </DropdownButton>
@@ -42,7 +63,7 @@ const MemberRoleMenu = ({ member, value, roles, onChange }) => {
42
63
  transformOrigin={{ vertical: 'top', horizontal: 'left' }}
43
64
  keepMounted
44
65
  autoClose
45
- onClose={() => setMenuDisplayed(false)}
66
+ onClose={closeMenu}
46
67
  >
47
68
  {/* The header renders nothing on desktop but would still take the initial focus of the menu */}
48
69
  {isHeaderShown && <MemberRoleMenuHeader member={member} />}
@@ -67,7 +88,11 @@ MemberRoleMenu.propTypes = {
67
88
  member: memberPropType,
68
89
  value: PropTypes.string.isRequired,
69
90
  roles: rolesPropType.isRequired,
70
- onChange: PropTypes.func.isRequired
91
+ size: PropTypes.oneOf(['small', 'medium', 'large']),
92
+ disabled: PropTypes.bool,
93
+ onChange: PropTypes.func.isRequired,
94
+ onOpen: PropTypes.func,
95
+ onClose: PropTypes.func
71
96
  }
72
97
 
73
98
  export default MemberRoleMenu