@telus-uds/components-base 1.60.0 → 1.61.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 (51) hide show
  1. package/CHANGELOG.md +10 -2
  2. package/component-docs.json +281 -70
  3. package/lib/Autocomplete/Autocomplete.js +483 -0
  4. package/lib/Autocomplete/Loading.js +51 -0
  5. package/lib/Autocomplete/Suggestions.js +85 -0
  6. package/lib/Autocomplete/constants.js +14 -0
  7. package/lib/Autocomplete/dictionary.js +19 -0
  8. package/lib/Autocomplete/index.js +13 -0
  9. package/lib/Listbox/GroupControl.js +121 -0
  10. package/lib/Listbox/Listbox.js +198 -0
  11. package/lib/Listbox/ListboxGroup.js +142 -0
  12. package/lib/Listbox/ListboxItem.js +97 -0
  13. package/lib/Listbox/ListboxOverlay.js +106 -0
  14. package/lib/Listbox/PressableItem.js +0 -2
  15. package/lib/Listbox/index.js +5 -24
  16. package/lib/TextInput/TextInputBase.js +2 -0
  17. package/lib/index.js +17 -13
  18. package/lib/utils/useOverlaidPosition.js +6 -4
  19. package/lib-module/Autocomplete/Autocomplete.js +448 -0
  20. package/lib-module/Autocomplete/Loading.js +36 -0
  21. package/lib-module/Autocomplete/Suggestions.js +66 -0
  22. package/lib-module/Autocomplete/constants.js +4 -0
  23. package/lib-module/Autocomplete/dictionary.js +12 -0
  24. package/lib-module/Autocomplete/index.js +2 -0
  25. package/lib-module/Listbox/GroupControl.js +102 -0
  26. package/lib-module/Listbox/Listbox.js +172 -0
  27. package/lib-module/Listbox/ListboxGroup.js +117 -0
  28. package/lib-module/Listbox/ListboxItem.js +71 -0
  29. package/lib-module/Listbox/ListboxOverlay.js +80 -0
  30. package/lib-module/Listbox/PressableItem.js +0 -2
  31. package/lib-module/Listbox/index.js +2 -2
  32. package/lib-module/TextInput/TextInputBase.js +2 -0
  33. package/lib-module/index.js +2 -1
  34. package/lib-module/utils/useOverlaidPosition.js +5 -4
  35. package/package.json +4 -2
  36. package/src/Autocomplete/Autocomplete.jsx +411 -0
  37. package/src/Autocomplete/Loading.jsx +18 -0
  38. package/src/Autocomplete/Suggestions.jsx +54 -0
  39. package/src/Autocomplete/constants.js +4 -0
  40. package/src/Autocomplete/dictionary.js +12 -0
  41. package/src/Autocomplete/index.js +3 -0
  42. package/src/Listbox/GroupControl.jsx +93 -0
  43. package/src/Listbox/Listbox.jsx +165 -0
  44. package/src/Listbox/ListboxGroup.jsx +120 -0
  45. package/src/Listbox/ListboxItem.jsx +76 -0
  46. package/src/Listbox/ListboxOverlay.jsx +82 -0
  47. package/src/Listbox/PressableItem.jsx +0 -2
  48. package/src/Listbox/index.js +3 -2
  49. package/src/TextInput/TextInputBase.jsx +2 -0
  50. package/src/index.js +2 -1
  51. package/src/utils/useOverlaidPosition.js +6 -5
@@ -0,0 +1,165 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
2
+ import PropTypes from 'prop-types'
3
+ import { View, StyleSheet, Platform } from 'react-native'
4
+ import { useThemeTokens } from '../ThemeProvider'
5
+ import { withLinkRouter, getTokensPropType } from '../utils'
6
+ import ExpandCollapse from '../ExpandCollapse'
7
+ import ListboxGroup from './ListboxGroup'
8
+ import ListboxItem from './ListboxItem'
9
+ import { ListboxContext } from './ListboxContext'
10
+ import DropdownOverlay from './ListboxOverlay'
11
+
12
+ const styles = StyleSheet.create({
13
+ list: {
14
+ padding: 0,
15
+ margin: 0
16
+ }
17
+ })
18
+
19
+ const getInitialOpen = (items, selectedId) =>
20
+ items
21
+ .filter(
22
+ (item) =>
23
+ item.items &&
24
+ item.items.some((nestedItem) => (nestedItem.id ?? nestedItem.label) === selectedId)
25
+ )
26
+ .map((item) => item.id ?? item.label)
27
+
28
+ const Listbox = ({
29
+ items = [],
30
+ firstItemRef = null, // focus will be moved to this one once within the menu
31
+ parentRef = null, // to return focus to after leaving the last menu item
32
+ selectedId: defaultSelectedId,
33
+ LinkRouter,
34
+ itemRouterProps,
35
+ onClose,
36
+ variant,
37
+ tokens
38
+ }) => {
39
+ const initialOpen = getInitialOpen(items, defaultSelectedId)
40
+
41
+ const [selectedId, setSelectedId] = useState(defaultSelectedId)
42
+
43
+ const { minHeight, minWidth } = useThemeTokens('Listbox', variant, tokens)
44
+
45
+ // We need to keep track of each item's ref in order to be able to
46
+ // focus on a specific item via keyboard navigation
47
+ const itemRefs = useRef([])
48
+ if (firstItemRef?.current) itemRefs.current[0] = firstItemRef.current
49
+ const [focusedIndex, setFocusedIndex] = useState(0)
50
+ const handleKeydown = useCallback(
51
+ (event) => {
52
+ const nextItemRef = itemRefs.current[focusedIndex + 1]
53
+ const prevItemRef = itemRefs.current[focusedIndex - 1]
54
+ if (event.key === 'ArrowUp' || (event.shiftKey && event.key === 'Tab')) {
55
+ // Move the focus to the previous item or to the parent one if on the first
56
+ if (prevItemRef) {
57
+ event.preventDefault()
58
+ prevItemRef.focus()
59
+ } else if (parentRef) parentRef.current?.focus()
60
+ setFocusedIndex(focusedIndex - 1)
61
+ } else if ((event.key === 'ArrowDown' || event.key === 'Tab') && nextItemRef) {
62
+ event.preventDefault()
63
+ setFocusedIndex(focusedIndex + 1)
64
+ nextItemRef.focus()
65
+ } else if (event.key === 'Escape') {
66
+ // Close the dropdown
67
+ parentRef?.current?.click()
68
+ // Return focus to the dropdown control after leaving the last item
69
+ parentRef?.current?.focus()
70
+ if (onClose) onClose(event)
71
+ }
72
+ },
73
+ [focusedIndex, onClose, parentRef]
74
+ )
75
+
76
+ // Add listeners for mouse clicks outside and for key presses
77
+ useEffect(() => {
78
+ if (Platform.OS === 'web') {
79
+ window.addEventListener('click', onClose)
80
+ window.addEventListener('keydown', handleKeydown)
81
+ window.addEventListener('touchstart', onClose)
82
+ return () => {
83
+ window.removeEventListener('click', onClose)
84
+ window.removeEventListener('keydown', handleKeydown)
85
+ window.removeEventListener('touchstart', onClose)
86
+ }
87
+ }
88
+ return () => {}
89
+ }, [onClose, handleKeydown])
90
+
91
+ return (
92
+ <ListboxContext.Provider value={{ selectedId, setSelectedId }}>
93
+ <ExpandCollapse initialOpen={initialOpen} maxOpen={1}>
94
+ {(expandProps) => (
95
+ <View style={[styles.list, { minHeight, minWidth }]} role="listbox">
96
+ {items.map((item, index) => {
97
+ const { id, label, items: nestedItems } = item
98
+ const itemId = id ?? label
99
+
100
+ // Give the list of refs.
101
+ const itemRef = (ref) => {
102
+ itemRefs.current[index] = ref
103
+ return ref
104
+ }
105
+
106
+ return nestedItems ? (
107
+ <ListboxGroup
108
+ {...item}
109
+ expandProps={expandProps}
110
+ LinkRouter={LinkRouter}
111
+ itemRouterProps={itemRouterProps}
112
+ prevItemRef={itemRefs.current[index - 1] ?? null}
113
+ nextItemRef={itemRefs.current[index + 1] ?? null}
114
+ ref={index === 0 ? firstItemRef : itemRef}
115
+ key={itemId}
116
+ />
117
+ ) : (
118
+ <ListboxItem
119
+ {...item}
120
+ key={itemId}
121
+ id={itemId}
122
+ LinkRouter={LinkRouter}
123
+ itemRouterProps={itemRouterProps}
124
+ prevItemRef={itemRefs.current[index - 1] ?? null}
125
+ nextItemRef={itemRefs.current[index + 1] ?? null}
126
+ ref={index === 0 ? firstItemRef : itemRef}
127
+ />
128
+ )
129
+ })}
130
+ </View>
131
+ )}
132
+ </ExpandCollapse>
133
+ </ListboxContext.Provider>
134
+ )
135
+ }
136
+
137
+ Listbox.propTypes = {
138
+ ...withLinkRouter.propTypes,
139
+ /**
140
+ * Focus will be moved to the item with this ref once within the menu.
141
+ */
142
+ firstItemRef: PropTypes.object,
143
+ /**
144
+ * Focus will be returned to the dropdown control with this ref after leaving
145
+ * the last menu item.
146
+ */
147
+ parentRef: PropTypes.object,
148
+ /**
149
+ * `Listbox` items
150
+ */
151
+ items: PropTypes.array,
152
+ /**
153
+ * To select an item by default
154
+ */
155
+ selectedId: PropTypes.string,
156
+ /**
157
+ * onClose event
158
+ */
159
+ onClose: PropTypes.func,
160
+ tokens: getTokensPropType('Listbox')
161
+ }
162
+
163
+ Listbox.Overlay = DropdownOverlay
164
+
165
+ export default Listbox
@@ -0,0 +1,120 @@
1
+ /* eslint-disable react-native-a11y/has-valid-accessibility-role */
2
+ import React, { forwardRef } from 'react'
3
+ import PropTypes from 'prop-types'
4
+ import { View, StyleSheet } from 'react-native'
5
+ import { withLinkRouter } from '../utils'
6
+ import ExpandCollapse from '../ExpandCollapse'
7
+ import ListboxItem from './ListboxItem'
8
+ import { useListboxContext } from './ListboxContext'
9
+ import GroupControl from './GroupControl'
10
+
11
+ const styles = StyleSheet.create({
12
+ groupWrapper: {
13
+ margin: 0,
14
+ padding: 0,
15
+ overflow: 'hidden'
16
+ },
17
+ list: {
18
+ margin: 0,
19
+ padding: 0
20
+ }
21
+ })
22
+
23
+ const ListboxGroup = forwardRef(
24
+ (
25
+ {
26
+ id,
27
+ label,
28
+ items,
29
+ LinkRouter,
30
+ linkRouterProps,
31
+ expandProps,
32
+ onLastItemBlur,
33
+ nextItemRef,
34
+ prevItemRef
35
+ },
36
+ ref
37
+ ) => {
38
+ const { selectedId } = useListboxContext()
39
+
40
+ // TODO: implement keyboard navigation via refs for grouped items separately here
41
+ return (
42
+ <View id="test" style={styles.groupWrapper} accessibilityRole="listitem">
43
+ <ExpandCollapse.Panel
44
+ panelId={id ?? label}
45
+ controlTokens={{
46
+ icon: null,
47
+ paddingLeft: 0,
48
+ paddingRight: 0,
49
+ paddingTop: 0,
50
+ paddingBottom: 0,
51
+ backgroundColor: 'transparent',
52
+ borderColor: 'transparent',
53
+ textLine: 'none',
54
+ borderWidth: 0
55
+ }}
56
+ // TODO refactor
57
+ // eslint-disable-next-line react/no-unstable-nested-components
58
+ control={(controlProps) => (
59
+ <GroupControl id={id ?? label} {...controlProps} label={label} />
60
+ )}
61
+ {...expandProps}
62
+ tokens={{
63
+ contentPaddingLeft: 0,
64
+ contentPaddingRight: 0,
65
+ contentPaddingTop: 0,
66
+ contentPaddingBottom: 0,
67
+ borderColor: 'transparent',
68
+ borderRadius: 0,
69
+ borderWidth: 0,
70
+ marginBottom: 0
71
+ }}
72
+ controlRef={ref}
73
+ >
74
+ <View style={styles.list}>
75
+ {items.map((item, index) => {
76
+ return (
77
+ <ListboxItem
78
+ key={item.label}
79
+ id={item.id ?? item.label}
80
+ {...item}
81
+ selected={
82
+ (item.id && item.id === selectedId) || (item.label && item.label === selectedId)
83
+ }
84
+ isChild
85
+ LinkRouter={LinkRouter}
86
+ linkRouterProps={linkRouterProps}
87
+ {...(index === 0 && { prevItemRef })}
88
+ {...(index === items.length - 1 && { nextItemRef })}
89
+ {...(index === items.length - 1 && { onBlur: onLastItemBlur })}
90
+ />
91
+ )
92
+ })}
93
+ </View>
94
+ </ExpandCollapse.Panel>
95
+ </View>
96
+ )
97
+ }
98
+ )
99
+ ListboxGroup.displayName = 'ListboxGroup'
100
+
101
+ ListboxGroup.propTypes = {
102
+ ...withLinkRouter.propTypes,
103
+ label: PropTypes.string,
104
+ items: PropTypes.arrayOf(
105
+ PropTypes.shape({
106
+ href: PropTypes.string,
107
+ label: PropTypes.string,
108
+ current: PropTypes.bool
109
+ })
110
+ ),
111
+ expandProps: PropTypes.object,
112
+ nextItemRef: PropTypes.object,
113
+ prevItemRef: PropTypes.object,
114
+ /**
115
+ * Use this callback to redirect the focus after it leaves the last item of the group.
116
+ */
117
+ onLastItemBlur: PropTypes.func
118
+ }
119
+
120
+ export default ListboxGroup
@@ -0,0 +1,76 @@
1
+ /* eslint-disable react/require-default-props */
2
+ import React, { forwardRef } from 'react'
3
+ import PropTypes from 'prop-types'
4
+ import { View, StyleSheet } from 'react-native'
5
+ import { selectSystemProps, withLinkRouter, htmlAttrs } from '../utils'
6
+ import PressableItem from './PressableItem'
7
+ import { useThemeTokensCallback } from '../ThemeProvider'
8
+
9
+ const [selectProps, selectedSystemPropTypes] = selectSystemProps([htmlAttrs])
10
+
11
+ const styles = StyleSheet.create({
12
+ itemContainer: {
13
+ display: 'flex',
14
+ margin: 0
15
+ },
16
+ childContainer: {
17
+ paddingLeft: 16
18
+ }
19
+ })
20
+
21
+ const ListboxItem = forwardRef(
22
+ (
23
+ {
24
+ href,
25
+ label,
26
+ isChild = false,
27
+ onBlur,
28
+ nextItemRef,
29
+ prevItemRef,
30
+ tokens,
31
+ variant = {},
32
+ LinkRouter,
33
+ linkRouterProps,
34
+ id,
35
+ onPress = () => {},
36
+ ...rest
37
+ },
38
+ ref
39
+ ) => {
40
+ const selectedProps = selectProps({ href, ...rest })
41
+
42
+ const getTokens = useThemeTokensCallback('Listbox', tokens, variant, { isChild })
43
+ return (
44
+ <View style={[styles.itemContainer, isChild && styles.childContainer]} role="option">
45
+ <PressableItem
46
+ href={href}
47
+ isChild={isChild}
48
+ onPress={onPress}
49
+ onBlur={onBlur}
50
+ nextItemRef={nextItemRef}
51
+ prevItemRef={prevItemRef}
52
+ ref={ref}
53
+ tokens={getTokens}
54
+ selectedProps={selectedProps}
55
+ id={id}
56
+ >
57
+ {label}
58
+ </PressableItem>
59
+ </View>
60
+ )
61
+ }
62
+ )
63
+ ListboxItem.displayName = 'ListboxItem'
64
+
65
+ ListboxItem.propTypes = {
66
+ ...selectedSystemPropTypes,
67
+ ...withLinkRouter.propTypes,
68
+ href: PropTypes.string,
69
+ isChild: PropTypes.bool,
70
+ label: PropTypes.node.isRequired,
71
+ nextItemRef: PropTypes.object,
72
+ prevItemRef: PropTypes.object,
73
+ onPress: PropTypes.func
74
+ }
75
+
76
+ export default withLinkRouter(ListboxItem)
@@ -0,0 +1,82 @@
1
+ /* eslint-disable react/require-default-props */
2
+ import React, { forwardRef } from 'react'
3
+ import PropTypes from 'prop-types'
4
+ import { View, StyleSheet, Platform } from 'react-native'
5
+ import { Portal } from '@gorhom/portal'
6
+ import { useThemeTokens } from '../ThemeProvider'
7
+ import Card from '../Card'
8
+
9
+ const staticStyles = StyleSheet.create({
10
+ positioner: {
11
+ flex: 1, // Grow to maxWidth when possible, shrink when not possible
12
+ position: 'absolute',
13
+ zIndex: 1000000000000000 // Position on top of all the other overlays, including backdrops and modals
14
+ },
15
+ hidden: {
16
+ // Use opacity not visibility to hide the dropdown during positioning
17
+ // so on web, children may be focused from the first render
18
+ opacity: 0
19
+ }
20
+ })
21
+
22
+ const paddingVertical = 0
23
+ const paddingHorizontal = 0
24
+
25
+ const DropdownOverlay = forwardRef(
26
+ ({ children, isReady = false, overlaidPosition, maxWidth, minWidth, onLayout }, ref) => {
27
+ const systemTokens = useThemeTokens('Listbox', {}, {})
28
+
29
+ return (
30
+ <View
31
+ ref={ref}
32
+ onLayout={onLayout}
33
+ style={[
34
+ overlaidPosition,
35
+ { maxWidth, minWidth },
36
+ staticStyles.positioner,
37
+ !isReady && staticStyles.hidden
38
+ ]}
39
+ >
40
+ <Card
41
+ tokens={{
42
+ shadow: systemTokens.shadow,
43
+ paddingBottom: paddingVertical,
44
+ paddingTop: paddingVertical,
45
+ paddingLeft: paddingHorizontal,
46
+ paddingRight: paddingHorizontal
47
+ }}
48
+ >
49
+ {children}
50
+ </Card>
51
+ </View>
52
+ )
53
+ }
54
+ )
55
+
56
+ const withPortal = (Overlay) => {
57
+ // eslint-disable-next-line react/display-name, react/no-multi-comp
58
+ return (props) => {
59
+ return (
60
+ <Portal>
61
+ <Overlay {...props} />
62
+ </Portal>
63
+ )
64
+ }
65
+ }
66
+
67
+ DropdownOverlay.displayName = 'DropdownOverlay'
68
+
69
+ DropdownOverlay.propTypes = {
70
+ children: PropTypes.node.isRequired,
71
+ isReady: PropTypes.bool,
72
+ overlaidPosition: PropTypes.shape({
73
+ top: PropTypes.number,
74
+ left: PropTypes.number,
75
+ width: PropTypes.number
76
+ }),
77
+ maxWidth: PropTypes.number,
78
+ minWidth: PropTypes.number,
79
+ onLayout: PropTypes.func
80
+ }
81
+
82
+ export default Platform.OS === 'web' ? withPortal(DropdownOverlay) : DropdownOverlay
@@ -18,7 +18,6 @@ const getItemStyles = ({
18
18
  itemPaddingRight,
19
19
  itemBackgroundColor,
20
20
  itemColor,
21
- itemDisplay,
22
21
  itemOutline,
23
22
  itemTextDecoration,
24
23
  itemBorderLeftColor,
@@ -39,7 +38,6 @@ const getItemStyles = ({
39
38
  width: '100%',
40
39
  backgroundColor: itemBackgroundColor,
41
40
  color: itemColor,
42
- display: itemDisplay,
43
41
  outline: itemOutline,
44
42
  textDecoration: itemTextDecoration,
45
43
  borderLeft: `${itemBorderLeftWidth}px solid ${itemBorderLeftColor}`,
@@ -1,2 +1,3 @@
1
- export * from './ListboxContext'
2
- export { default as PressableItem } from './PressableItem'
1
+ import Listbox from './Listbox'
2
+
3
+ export default Listbox
@@ -341,6 +341,8 @@ export default TextInputBase
341
341
  const staticStyles = StyleSheet.create({
342
342
  buttonsContainer: {
343
343
  position: 'absolute',
344
+ flexDirection: 'row',
345
+ alignItems: 'center',
344
346
  right: 0,
345
347
  top: 0,
346
348
  bottom: 0,
package/src/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  export { default as A11yText } from './A11yText'
2
2
  export { default as ActivityIndicator } from './ActivityIndicator'
3
+ export { default as Autocomplete } from './Autocomplete'
3
4
  export { default as Box } from './Box'
4
5
  export * from './Button'
5
6
  export { default as Card, PressableCardBase } from './Card'
6
7
  export * from './Carousel'
7
- export * from './Listbox'
8
+ export { default as Listbox } from './Listbox'
8
9
  export { default as Checkbox } from './Checkbox'
9
10
  export * from './Checkbox'
10
11
  export { default as Divider } from './Divider'
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
- import { Dimensions } from 'react-native'
2
+ import { Dimensions, Platform } from 'react-native'
3
3
 
4
4
  const adjustHorizontalToFit = (initialOffset, windowWidth, sourceWidth) => {
5
5
  const offset = Math.max(0, initialOffset)
@@ -169,8 +169,9 @@ const useOverlaidPosition = ({
169
169
  const readyToShow = Boolean(isShown && sourceRef.current)
170
170
  useEffect(() => {
171
171
  const handleDimensionsChange = ({ window }) => {
172
- sourceRef.current?.measureInWindow((x, y, width, height) => {
173
- // Could add a debouncer here if there's too many rerenders during gradual resizes
172
+ const measurementFunction = Platform.OS === 'web' ? 'measureInWindow' : 'measure'
173
+
174
+ sourceRef.current?.[measurementFunction]((x, y, width, height) => {
174
175
  setWindowDimensions(window)
175
176
  setSourceLayout({ x, y, width, height })
176
177
  })
@@ -181,9 +182,9 @@ const useOverlaidPosition = ({
181
182
  if (typeof subscription?.remove === 'function') {
182
183
  // React Native >=0.65.0
183
184
  subscription.remove()
184
- } else if (typeof Dimensions.removeEventListener === 'function') {
185
+ } else if (typeof Dimensions.remove === 'function') {
185
186
  // React Native <0.65.0
186
- Dimensions.removeEventListener('change', handleDimensionsChange)
187
+ Dimensions.remove('change', handleDimensionsChange)
187
188
  }
188
189
  setSourceLayout(null)
189
190
  setTargetDimensions(null)