@telus-uds/components-base 1.34.0 → 1.34.2

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.
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
3
3
  import View from "react-native-web/dist/exports/View";
4
4
  import StyleSheet from "react-native-web/dist/exports/StyleSheet";
5
5
  import { Portal } from '@gorhom/portal';
6
- import { copyPropTypes, getTokensPropType, selectTokens, useCopy, variantProp } from '../utils';
6
+ import { selectTokens, useCopy, copyPropTypes, getTokensPropType, variantProp } from '../utils';
7
7
  import { useViewport } from '../ViewportProvider';
8
8
  import { useThemeTokens } from '../ThemeProvider';
9
9
  import dictionary from './dictionary';
@@ -11,14 +11,12 @@ import Card from '../Card';
11
11
  import IconButton from '../IconButton';
12
12
  import { jsx as _jsx } from "react/jsx-runtime";
13
13
  import { jsxs as _jsxs } from "react/jsx-runtime";
14
- import { Fragment as _Fragment } from "react/jsx-runtime";
15
14
  const staticStyles = StyleSheet.create({
16
15
  positioner: {
17
16
  flex: 1,
18
17
  // Grow to maxWidth when possible, shrink when not possible
19
18
  position: 'absolute',
20
19
  height: 330,
21
- paddingTop: 5,
22
20
  zIndex: 10000 // Position on top of all the other overlays, including backdrops and modals
23
21
 
24
22
  },
@@ -27,6 +25,11 @@ const staticStyles = StyleSheet.create({
27
25
  top: 0,
28
26
  right: 0,
29
27
  zIndex: 1
28
+ },
29
+ hidden: {
30
+ // Use opacity not visibility to hide the dropdown during positioning
31
+ // so on web, children may be focused from the first render
32
+ opacity: 0
30
33
  }
31
34
  });
32
35
 
@@ -58,8 +61,11 @@ const selectPaddingContainerStyles = _ref2 => {
58
61
  const ModalOverlay = /*#__PURE__*/forwardRef((_ref3, ref) => {
59
62
  let {
60
63
  children,
61
- tokens,
64
+ isReady = false,
65
+ overlaidPosition,
66
+ onLayout,
62
67
  variant,
68
+ tokens,
63
69
  copy,
64
70
  onClose
65
71
  } = _ref3;
@@ -77,26 +83,25 @@ const ModalOverlay = /*#__PURE__*/forwardRef((_ref3, ref) => {
77
83
  copy
78
84
  });
79
85
  const closeLabel = getCopy('closeButton');
80
- return /*#__PURE__*/_jsx(_Fragment, {
81
- children: /*#__PURE__*/_jsx(Portal, {
86
+ return /*#__PURE__*/_jsx(Portal, {
87
+ children: /*#__PURE__*/_jsx(View, {
82
88
  ref: ref,
83
- children: /*#__PURE__*/_jsx(View, {
84
- style: [{
85
- minWidth: maxWidth
86
- }, staticStyles.positioner],
87
- children: /*#__PURE__*/_jsxs(Card, {
88
- tokens: selectPaddingContainerStyles(themeTokens),
89
- children: [/*#__PURE__*/_jsx(View, {
90
- style: [staticStyles.closeButtonContainer, selectCloseButtonContainerStyles(themeTokens)],
91
- children: /*#__PURE__*/_jsx(IconButton, {
92
- onPress: onClose,
93
- icon: CloseIconComponent,
94
- accessibilityRole: "button",
95
- accessibilityLabel: closeLabel,
96
- tokens: selectTokens('IconButton', themeTokens, 'close')
97
- })
98
- }), children]
99
- })
89
+ onLayout: onLayout,
90
+ style: [overlaidPosition, {
91
+ minWidth: maxWidth
92
+ }, staticStyles.positioner, !isReady && staticStyles.hidden],
93
+ children: /*#__PURE__*/_jsxs(Card, {
94
+ tokens: selectPaddingContainerStyles(themeTokens),
95
+ children: [/*#__PURE__*/_jsx(View, {
96
+ style: [staticStyles.closeButtonContainer, selectCloseButtonContainerStyles(themeTokens)],
97
+ children: /*#__PURE__*/_jsx(IconButton, {
98
+ onPress: onClose,
99
+ icon: CloseIconComponent,
100
+ accessibilityRole: "button",
101
+ accessibilityLabel: closeLabel,
102
+ tokens: selectTokens('IconButton', themeTokens, 'close')
103
+ })
104
+ }), children]
100
105
  })
101
106
  })
102
107
  });
@@ -104,6 +109,13 @@ const ModalOverlay = /*#__PURE__*/forwardRef((_ref3, ref) => {
104
109
  ModalOverlay.displayName = 'ModalOverlay';
105
110
  ModalOverlay.propTypes = {
106
111
  children: PropTypes.node.isRequired,
112
+ isReady: PropTypes.bool,
113
+ overlaidPosition: PropTypes.shape({
114
+ top: PropTypes.number,
115
+ left: PropTypes.number,
116
+ width: PropTypes.number
117
+ }),
118
+ onLayout: PropTypes.func,
107
119
  variant: variantProp.propType,
108
120
  tokens: getTokensPropType('Modal'),
109
121
  copy: copyPropTypes,
@@ -1,7 +1,7 @@
1
- import React, { forwardRef, useState } from 'react';
1
+ import React, { useState } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import { useThemeTokens, useThemeTokensCallback } from '../ThemeProvider';
4
- import { containUniqueFields, getTokensPropType, getPressHandlersWithArgs, selectTokens, useCopy, useMultipleInputValues, variantProp } from '../utils';
4
+ import { containUniqueFields, getTokensPropType, getPressHandlersWithArgs, selectTokens, useOverlaidPosition, useCopy, useMultipleInputValues, useResponsiveProp, variantProp } from '../utils';
5
5
  import dictionary from './dictionary';
6
6
  import Box from '../Box';
7
7
  import { Button, ButtonDropdown } from '../Button';
@@ -45,7 +45,7 @@ const selectDividerToknes = _ref2 => {
45
45
  };
46
46
  };
47
47
 
48
- const MultiSelectFilter = /*#__PURE__*/forwardRef((_ref3, ref) => {
48
+ const MultiSelectFilter = _ref3 => {
49
49
  let {
50
50
  label,
51
51
  subtitle,
@@ -109,9 +109,41 @@ const MultiSelectFilter = /*#__PURE__*/forwardRef((_ref3, ref) => {
109
109
  setIsOpen(false);
110
110
  };
111
111
 
112
+ const {
113
+ align,
114
+ offsets
115
+ } = useResponsiveProp({
116
+ xs: {
117
+ align: {
118
+ top: 'top',
119
+ left: 'left',
120
+ bottom: 'bottom',
121
+ right: 'right'
122
+ }
123
+ },
124
+ sm: {
125
+ align: {
126
+ top: 'bottom',
127
+ left: 'left'
128
+ },
129
+ offsets: {
130
+ vertical: 4
131
+ }
132
+ }
133
+ });
134
+ const {
135
+ overlaidPosition,
136
+ onTargetLayout,
137
+ isReady,
138
+ sourceRef
139
+ } = useOverlaidPosition({
140
+ isShown: isOpen,
141
+ offsets,
142
+ align
143
+ });
112
144
  return /*#__PURE__*/_jsxs(_Fragment, {
113
145
  children: [/*#__PURE__*/_jsx(ButtonDropdown, {
114
- ref: ref,
146
+ ref: sourceRef,
115
147
  ...pressHandlers,
116
148
  value: isOpen,
117
149
  selected: isSelected,
@@ -120,10 +152,15 @@ const MultiSelectFilter = /*#__PURE__*/forwardRef((_ref3, ref) => {
120
152
  tokens: getButtonTokens,
121
153
  inactive: inactive
122
154
  }, id), isOpen && /*#__PURE__*/_jsxs(ModalOverlay, {
155
+ overlaidPosition: overlaidPosition,
123
156
  variant: {
124
157
  width: colSize > 1 ? 'size576' : 's'
125
158
  },
126
159
  onClose: () => setIsOpen(false),
160
+ tokens: tokens,
161
+ copy: copy,
162
+ isReady: isReady,
163
+ onLayout: onTargetLayout,
127
164
  children: [/*#__PURE__*/_jsx(Row, {
128
165
  children: /*#__PURE__*/_jsx(Typography, {
129
166
  variant: {
@@ -189,7 +226,8 @@ const MultiSelectFilter = /*#__PURE__*/forwardRef((_ref3, ref) => {
189
226
  })]
190
227
  })]
191
228
  });
192
- });
229
+ };
230
+
193
231
  MultiSelectFilter.displayName = 'MultiSelectFilter';
194
232
  MultiSelectFilter.propTypes = {
195
233
  /**
@@ -31,8 +31,8 @@ const selectHighlightBarStyles = _ref => {
31
31
  borderRadius: highlightBarBorderRadius,
32
32
  borderWidth: highlightBarBorderWidth,
33
33
  bottom: -1 * (highlightBarHeight + highlightBarBorderWidth),
34
- left: -1 * highlightBarBorderWidth,
35
- right: -1 * highlightBarBorderWidth,
34
+ left: 0,
35
+ right: 0,
36
36
  zIndex: 1 + highlightBarBorderWidth
37
37
  };
38
38
  };
@@ -9,6 +9,7 @@ export { default as useCopy } from './useCopy';
9
9
  export { default as useHash } from './useHash';
10
10
  export { default as useSpacingScale } from './useSpacingScale';
11
11
  export { default as useResponsiveProp } from './useResponsiveProp';
12
+ export { default as useOverlaidPosition } from './useOverlaidPosition';
12
13
  export { default as useSafeLayoutEffect } from './useSafeLayoutEffect';
13
14
  export { default as useScrollBlocking } from './useScrollBlocking';
14
15
  export * from './useResponsiveProp';
@@ -0,0 +1,232 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import Dimensions from "react-native-web/dist/exports/Dimensions";
3
+
4
+ const adjustHorizontalToFit = (initialOffset, windowWidth, sourceWidth) => {
5
+ const offset = Math.max(0, initialOffset);
6
+ const otherEdgeOverflow = Math.max(0, offset + sourceWidth - windowWidth);
7
+ const tooWideBy = Math.max(0, otherEdgeOverflow - offset);
8
+ const adjusted = {
9
+ offset: Math.max(0, offset - otherEdgeOverflow)
10
+ };
11
+ if (tooWideBy) adjusted.width = Math.max(0, sourceWidth - tooWideBy);
12
+ return adjusted;
13
+ };
14
+
15
+ const getPosition = _ref => {
16
+ let {
17
+ edge,
18
+ fromEdge,
19
+ sourceSize
20
+ } = _ref;
21
+
22
+ switch (edge) {
23
+ case 'near':
24
+ return fromEdge;
25
+
26
+ case 'mid':
27
+ return fromEdge + sourceSize / 2;
28
+
29
+ case 'far':
30
+ return fromEdge + sourceSize;
31
+
32
+ default:
33
+ return 0;
34
+ }
35
+ };
36
+
37
+ const getEdgeType = (align, alignSide) => {
38
+ const alignTo = align[alignSide];
39
+ const edge = ['center', 'middle'].includes(alignTo) && 'mid' || (alignSide === alignTo ? 'near' : 'far');
40
+ return edge;
41
+ };
42
+ /**
43
+ * Based on UDS's private getTooltipPosition but generalised.
44
+ *
45
+ * Used for absolute positioning of the tooltip. Since the tooltip is always centered relatively
46
+ * to the source (button) and we have a limited set of positions, an easy and consistent way
47
+ * of positioning it is to check all of the possible positions and pick one that will be rendered
48
+ * within the window bounds. This way we can also rely on the tooltip being actually rendered
49
+ * before it is shown, which makes it account for the width being limiting in styles, custom font
50
+ * rendering, etc.
51
+ */
52
+
53
+
54
+ function getOverlaidPosition(_ref2) {
55
+ let {
56
+ sourceLayout,
57
+ targetDimensions,
58
+ windowDimensions,
59
+ offsets = {},
60
+ align
61
+ } = _ref2;
62
+ // Web-only: this will be difficult to mimic on native because there's no global scroll position.
63
+ // TODO: wire something in e.g. a scroll ref accessible from a provider included in Allium provider
64
+ // that can be passed to the appropriate ScrollView?
65
+ const {
66
+ scrollX = 0,
67
+ scrollY = 0
68
+ } = typeof window === 'object' ? window : {}; // Will have top, bottom, left and/or right offsets depending on `align`
69
+
70
+ const positioning = {};
71
+ if (align.top) positioning.top = getPosition({
72
+ edge: getEdgeType(align, 'top'),
73
+ fromEdge: sourceLayout.y + scrollY + (offsets.vertical ?? 0),
74
+ sourceSize: sourceLayout.height
75
+ });
76
+ if (align.middle) positioning.top = getPosition({
77
+ edge: getEdgeType(align, 'middle'),
78
+ fromEdge: sourceLayout.y + scrollY + (offsets.vertical ?? 0) - targetDimensions.height / 2,
79
+ sourceSize: sourceLayout.height
80
+ });
81
+ if (align.bottom) positioning.bottom = getPosition({
82
+ edge: getEdgeType(align, 'bottom'),
83
+ fromEdge: windowDimensions.height - (sourceLayout.y + scrollY + sourceLayout.height - (offsets.vertical ?? 0)),
84
+ sourceSize: sourceLayout.height
85
+ });
86
+ if (align.left) positioning.left = getPosition({
87
+ edge: getEdgeType(align, 'left'),
88
+ fromEdge: sourceLayout.x + scrollX + (offsets.horizontal ?? 0),
89
+ sourceSize: sourceLayout.width
90
+ });
91
+ if (align.center) positioning.left = getPosition({
92
+ edge: getEdgeType(align, 'center'),
93
+ fromEdge: sourceLayout.x + scrollX + (offsets.horizontal ?? 0) - targetDimensions.width / 2,
94
+ sourceSize: sourceLayout.width
95
+ });
96
+ if (align.right) positioning.right = getPosition({
97
+ edge: getEdgeType(align, 'right'),
98
+ fromEdge: windowDimensions.width - (sourceLayout.x + scrollX + sourceLayout.width - (offsets.horizontal ?? 0)),
99
+ sourceSize: sourceLayout.width
100
+ });
101
+
102
+ if (!(align.left && align.right)) {
103
+ // Check if the position and/or width need adjusting to fit on the screen
104
+ const side = align.right ? 'right' : 'left';
105
+ const adjusted = adjustHorizontalToFit(positioning[side], windowDimensions.width, sourceLayout.width);
106
+ if (typeof adjusted.width === 'number') positioning.width = adjusted.width;
107
+
108
+ if (typeof adjusted.offset === 'number') {
109
+ positioning[side] = adjusted.offset;
110
+ }
111
+ }
112
+
113
+ return positioning;
114
+ }
115
+ /**
116
+ * Positions an element in a modal or portal so that it appears tooltip-like below the
117
+ * target element.
118
+ *
119
+ * @TODO - add support for positioning other than 'below' like UDS's tooltip (this is not
120
+ * a small task because UDS's tooltip logic only really works for short text - it might be
121
+ * better to use a third-party library).
122
+ */
123
+
124
+
125
+ const useOverlaidPosition = _ref3 => {
126
+ let {
127
+ isShown = false,
128
+ offsets,
129
+ // By default, align the overlaid target's `top` to the bottom of the source, and center horizontally.
130
+ align = {
131
+ center: 'center',
132
+ top: 'bottom'
133
+ }
134
+ } = _ref3;
135
+ // Element in main document flow that the targetRef element is positioned around
136
+ const sourceRef = useRef(null);
137
+ const [sourceLayout, setSourceLayout] = useState(null); // Element in a modal or portal overlay positioned to appear adjacent to sourceRef
138
+
139
+ const targetRef = useRef(null);
140
+ const [targetDimensions, setTargetDimensions] = useState(null);
141
+ const [windowDimensions, setWindowDimensions] = useState(null);
142
+ const onTargetLayout = useCallback(_ref4 => {
143
+ let {
144
+ nativeEvent: {
145
+ layout: {
146
+ width,
147
+ height
148
+ }
149
+ }
150
+ } = _ref4;
151
+ // NOTE: UDS's Tooltip logic injects some additional width to allow for antialiasing etc of text,
152
+ // avoiding adding unnecessary line breaks to text that is slightly wider than it thinks it is.
153
+ // That is probably something specific to text tooltips that doesn't belong in a generic hook.
154
+ setTargetDimensions(previousDimensions => {
155
+ // Re-render on first non-zero width / height: avoid infinite loops on changes, or mispositioning
156
+ // if user scrolls while a slidedown animation is changing the height and recalculating position.
157
+ if (!previousDimensions && width && height) {
158
+ return {
159
+ width,
160
+ height
161
+ };
162
+ }
163
+
164
+ return previousDimensions;
165
+ });
166
+ }, []);
167
+ const readyToShow = Boolean(isShown && sourceRef.current);
168
+ useEffect(() => {
169
+ const handleDimensionsChange = _ref5 => {
170
+ var _sourceRef$current;
171
+
172
+ let {
173
+ window
174
+ } = _ref5;
175
+ (_sourceRef$current = sourceRef.current) === null || _sourceRef$current === void 0 ? void 0 : _sourceRef$current.measureInWindow((x, y, width, height) => {
176
+ // Could add a debouncer here if there's too many rerenders during gradual resizes
177
+ setWindowDimensions(window);
178
+ setSourceLayout({
179
+ x,
180
+ y,
181
+ width,
182
+ height
183
+ });
184
+ });
185
+ };
186
+
187
+ let subscription;
188
+
189
+ const unsubscribe = () => {
190
+ var _subscription;
191
+
192
+ if (typeof ((_subscription = subscription) === null || _subscription === void 0 ? void 0 : _subscription.remove) === 'function') {
193
+ // React Native >=0.65.0
194
+ subscription.remove();
195
+ } else if (typeof Dimensions.removeEventListener === 'function') {
196
+ // React Native <0.65.0
197
+ Dimensions.removeEventListener('change', handleDimensionsChange);
198
+ }
199
+
200
+ setSourceLayout(null);
201
+ setTargetDimensions(null);
202
+ };
203
+
204
+ if (readyToShow) {
205
+ subscription = Dimensions.addEventListener('change', handleDimensionsChange);
206
+ handleDimensionsChange({
207
+ window: Dimensions.get('window')
208
+ });
209
+ } else {
210
+ unsubscribe();
211
+ }
212
+
213
+ return unsubscribe;
214
+ }, [readyToShow]);
215
+ const isReady = Boolean(isShown && sourceLayout && windowDimensions && targetDimensions);
216
+ const overlaidPosition = isReady ? getOverlaidPosition({
217
+ sourceLayout,
218
+ targetDimensions,
219
+ windowDimensions,
220
+ offsets,
221
+ align
222
+ }) : {};
223
+ return {
224
+ overlaidPosition,
225
+ sourceRef,
226
+ targetRef,
227
+ onTargetLayout,
228
+ isReady
229
+ };
230
+ };
231
+
232
+ export default useOverlaidPosition;
package/package.json CHANGED
@@ -11,7 +11,7 @@
11
11
  "@floating-ui/react-native": "^0.8.1",
12
12
  "@gorhom/portal": "^1.0.14",
13
13
  "@telus-uds/system-constants": "^1.2.0",
14
- "@telus-uds/system-theme-tokens": "^2.18.0",
14
+ "@telus-uds/system-theme-tokens": "^2.19.0",
15
15
  "airbnb-prop-types": "^2.16.0",
16
16
  "lodash.debounce": "^4.0.8",
17
17
  "lodash.merge": "^4.6.2",
@@ -72,5 +72,5 @@
72
72
  "standard-engine": {
73
73
  "skip": true
74
74
  },
75
- "version": "1.34.0"
75
+ "version": "1.34.2"
76
76
  }
@@ -1,8 +1,8 @@
1
1
  import React, { forwardRef } from 'react'
2
2
  import PropTypes from 'prop-types'
3
- import { View, StyleSheet } from 'react-native-web'
3
+ import { View, StyleSheet } from 'react-native'
4
4
  import { Portal } from '@gorhom/portal'
5
- import { copyPropTypes, getTokensPropType, selectTokens, useCopy, variantProp } from '../utils'
5
+ import { selectTokens, useCopy, copyPropTypes, getTokensPropType, variantProp } from '../utils'
6
6
  import { useViewport } from '../ViewportProvider'
7
7
  import { useThemeTokens } from '../ThemeProvider'
8
8
  import dictionary from './dictionary'
@@ -15,7 +15,6 @@ const staticStyles = StyleSheet.create({
15
15
  flex: 1, // Grow to maxWidth when possible, shrink when not possible
16
16
  position: 'absolute',
17
17
  height: 330,
18
- paddingTop: 5,
19
18
  zIndex: 10000 // Position on top of all the other overlays, including backdrops and modals
20
19
  },
21
20
  closeButtonContainer: {
@@ -23,6 +22,11 @@ const staticStyles = StyleSheet.create({
23
22
  top: 0,
24
23
  right: 0,
25
24
  zIndex: 1
25
+ },
26
+ hidden: {
27
+ // Use opacity not visibility to hide the dropdown during positioning
28
+ // so on web, children may be focused from the first render
29
+ opacity: 0
26
30
  }
27
31
  })
28
32
 
@@ -38,19 +42,31 @@ const selectPaddingContainerStyles = ({ paddingTop, paddingLeft, paddingRight })
38
42
  paddingRight
39
43
  })
40
44
 
41
- const ModalOverlay = forwardRef(({ children, tokens, variant, copy, onClose }, ref) => {
42
- const viewport = useViewport()
43
- const themeTokens = useThemeTokens('Modal', tokens, variant, { viewport, maxWidth: false })
45
+ const ModalOverlay = forwardRef(
46
+ (
47
+ { children, isReady = false, overlaidPosition, onLayout, variant, tokens, copy, onClose },
48
+ ref
49
+ ) => {
50
+ const viewport = useViewport()
51
+ const themeTokens = useThemeTokens('Modal', tokens, variant, { viewport, maxWidth: false })
44
52
 
45
- const { closeIcon: CloseIconComponent, maxWidth } = themeTokens
53
+ const { closeIcon: CloseIconComponent, maxWidth } = themeTokens
46
54
 
47
- const getCopy = useCopy({ dictionary, copy })
48
- const closeLabel = getCopy('closeButton')
55
+ const getCopy = useCopy({ dictionary, copy })
56
+ const closeLabel = getCopy('closeButton')
49
57
 
50
- return (
51
- <>
52
- <Portal ref={ref}>
53
- <View style={[{ minWidth: maxWidth }, staticStyles.positioner]}>
58
+ return (
59
+ <Portal>
60
+ <View
61
+ ref={ref}
62
+ onLayout={onLayout}
63
+ style={[
64
+ overlaidPosition,
65
+ { minWidth: maxWidth },
66
+ staticStyles.positioner,
67
+ !isReady && staticStyles.hidden
68
+ ]}
69
+ >
54
70
  <Card tokens={selectPaddingContainerStyles(themeTokens)}>
55
71
  <View
56
72
  style={[
@@ -70,13 +86,20 @@ const ModalOverlay = forwardRef(({ children, tokens, variant, copy, onClose }, r
70
86
  </Card>
71
87
  </View>
72
88
  </Portal>
73
- </>
74
- )
75
- })
89
+ )
90
+ }
91
+ )
76
92
  ModalOverlay.displayName = 'ModalOverlay'
77
93
 
78
94
  ModalOverlay.propTypes = {
79
95
  children: PropTypes.node.isRequired,
96
+ isReady: PropTypes.bool,
97
+ overlaidPosition: PropTypes.shape({
98
+ top: PropTypes.number,
99
+ left: PropTypes.number,
100
+ width: PropTypes.number
101
+ }),
102
+ onLayout: PropTypes.func,
80
103
  variant: variantProp.propType,
81
104
  tokens: getTokensPropType('Modal'),
82
105
  copy: copyPropTypes,