@telus-uds/components-base 3.29.1 → 3.31.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 (71) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/lib/cjs/Autocomplete/Autocomplete.js +13 -1
  3. package/lib/cjs/Autocomplete/constants.js +1 -1
  4. package/lib/cjs/Carousel/Carousel.js +6 -1
  5. package/lib/cjs/Carousel/CarouselThumbnail.js +123 -31
  6. package/lib/cjs/Carousel/CarouselThumbnailNavigation.js +8 -1
  7. package/lib/cjs/Footnote/FootnoteLink.js +18 -17
  8. package/lib/cjs/Listbox/ListboxOverlay.js +7 -1
  9. package/lib/cjs/MultiSelectFilter/ModalOverlay.js +7 -1
  10. package/lib/cjs/MultiSelectFilter/MultiSelectFilter.js +2 -28
  11. package/lib/cjs/Progress/Progress.js +30 -5
  12. package/lib/cjs/Scroll/Scroll.js +466 -0
  13. package/lib/cjs/Scroll/ScrollContext.js +55 -0
  14. package/lib/cjs/Scroll/ScrollItem.js +103 -0
  15. package/lib/cjs/Scroll/ScrollProgress.js +99 -0
  16. package/lib/cjs/Scroll/dictionary.js +18 -0
  17. package/lib/cjs/Scroll/index.js +29 -0
  18. package/lib/cjs/TextInput/TextInputBase.js +2 -1
  19. package/lib/cjs/index.js +15 -0
  20. package/lib/cjs/utils/animation/index.js +7 -0
  21. package/lib/cjs/utils/animation/useAnimatedValue.js +46 -0
  22. package/lib/cjs/utils/children.js +2 -1
  23. package/lib/cjs/utils/useOverlaidPosition.js +83 -42
  24. package/lib/esm/Autocomplete/Autocomplete.js +13 -1
  25. package/lib/esm/Autocomplete/constants.js +1 -1
  26. package/lib/esm/Carousel/Carousel.js +6 -1
  27. package/lib/esm/Carousel/CarouselThumbnail.js +124 -32
  28. package/lib/esm/Carousel/CarouselThumbnailNavigation.js +8 -1
  29. package/lib/esm/Footnote/FootnoteLink.js +18 -17
  30. package/lib/esm/Listbox/ListboxOverlay.js +7 -1
  31. package/lib/esm/MultiSelectFilter/ModalOverlay.js +8 -2
  32. package/lib/esm/MultiSelectFilter/MultiSelectFilter.js +2 -28
  33. package/lib/esm/Progress/Progress.js +30 -5
  34. package/lib/esm/Scroll/Scroll.js +459 -0
  35. package/lib/esm/Scroll/ScrollContext.js +47 -0
  36. package/lib/esm/Scroll/ScrollItem.js +96 -0
  37. package/lib/esm/Scroll/ScrollProgress.js +92 -0
  38. package/lib/esm/Scroll/dictionary.js +12 -0
  39. package/lib/esm/Scroll/index.js +4 -0
  40. package/lib/esm/TextInput/TextInputBase.js +2 -1
  41. package/lib/esm/index.js +1 -0
  42. package/lib/esm/utils/animation/index.js +1 -1
  43. package/lib/esm/utils/animation/useAnimatedValue.js +39 -0
  44. package/lib/esm/utils/children.js +2 -1
  45. package/lib/esm/utils/useOverlaidPosition.js +83 -42
  46. package/lib/package.json +2 -2
  47. package/package.json +2 -2
  48. package/src/Autocomplete/Autocomplete.jsx +11 -2
  49. package/src/Autocomplete/constants.js +1 -1
  50. package/src/Carousel/Carousel.jsx +6 -1
  51. package/src/Carousel/CarouselThumbnail.jsx +153 -64
  52. package/src/Carousel/CarouselThumbnailNavigation.jsx +8 -2
  53. package/src/Footnote/FootnoteLink.jsx +17 -12
  54. package/src/Listbox/ListboxOverlay.jsx +6 -2
  55. package/src/MultiSelectFilter/ModalOverlay.jsx +9 -3
  56. package/src/MultiSelectFilter/MultiSelectFilter.jsx +1 -31
  57. package/src/Progress/Progress.jsx +22 -3
  58. package/src/Scroll/Scroll.jsx +488 -0
  59. package/src/Scroll/ScrollContext.jsx +41 -0
  60. package/src/Scroll/ScrollItem.jsx +89 -0
  61. package/src/Scroll/ScrollProgress.jsx +75 -0
  62. package/src/Scroll/dictionary.js +12 -0
  63. package/src/Scroll/index.js +5 -0
  64. package/src/TextInput/TextInputBase.jsx +2 -1
  65. package/src/index.js +1 -0
  66. package/src/utils/animation/index.js +1 -1
  67. package/src/utils/animation/useAnimatedValue.js +37 -0
  68. package/src/utils/children.jsx +4 -1
  69. package/src/utils/useOverlaidPosition.js +84 -34
  70. package/types/Scroll.d.ts +66 -0
  71. package/types/index.d.ts +9 -0
@@ -0,0 +1,488 @@
1
+ import React from 'react'
2
+ import PropTypes from 'prop-types'
3
+ import { Platform, Pressable, StyleSheet, View } from 'react-native'
4
+
5
+ import { useThemeTokensCallback } from '../ThemeProvider'
6
+ import {
7
+ a11yProps,
8
+ copyPropTypes,
9
+ getTokensPropType,
10
+ selectSystemProps,
11
+ useCopy,
12
+ useUniqueId,
13
+ variantProp,
14
+ viewProps
15
+ } from '../utils'
16
+ import A11yText from '../A11yText'
17
+ import ScrollViewEnd from '../HorizontalScroll/ScrollViewEnd'
18
+ import dictionary from './dictionary'
19
+ import ScrollProgress from './ScrollProgress'
20
+ import { ScrollProvider } from './ScrollContext'
21
+
22
+ const [selectProps, selectedSystemPropTypes] = selectSystemProps([a11yProps, viewProps])
23
+
24
+ const SCROLL_DESCRIPTION_PREFIX = 'scroll-description'
25
+ const KEY_ARROW_RIGHT = 'ArrowRight'
26
+ const KEY_ARROW_LEFT = 'ArrowLeft'
27
+ const KEY_SPACE = ' '
28
+ const MAX_PERCENTAGE = 100
29
+ const MIN_BAR_PERCENTAGE = 1
30
+ const ROLE_REGION = 'region'
31
+ const ROLE_NONE = 'none'
32
+ const COMPONENT_NAME = 'Scroll'
33
+ const SCROLL_ITEM_SELECTOR = '[data-scroll-item]'
34
+ const TAB_INDEX = 0
35
+ const INITIAL_DIMENSION = 0
36
+ const HALF_DIVISOR = 2
37
+
38
+ const selectPressableStyles = ({ progressHitAreaHeight, progressSpacing }) => ({
39
+ height: progressHitAreaHeight,
40
+ marginTop: progressSpacing
41
+ })
42
+
43
+ const selectThumbWrapperStyles = ({ thumbWidthPercent, thumbPositionPercent }) => ({
44
+ width: `${thumbWidthPercent}%`,
45
+ marginLeft: `${thumbPositionPercent}%`
46
+ })
47
+
48
+ const selectContainerA11yProps = ({ accessibilityLabel, descriptionId, handleKeyDown }) =>
49
+ Platform.OS === 'web'
50
+ ? {
51
+ role: ROLE_REGION,
52
+ 'aria-label': accessibilityLabel,
53
+ 'aria-describedby': descriptionId,
54
+ tabIndex: TAB_INDEX,
55
+ onKeyDown: handleKeyDown
56
+ }
57
+ : {
58
+ accessibilityRole: ROLE_NONE,
59
+ accessibilityLabel
60
+ }
61
+
62
+ const selectScrollContentStyle = (itemGap) => ({
63
+ columnGap: itemGap
64
+ })
65
+
66
+ const calculateThumbWidth = ({ containerWidth, contentWidth }) => {
67
+ if (contentWidth <= INITIAL_DIMENSION) return MAX_PERCENTAGE
68
+ return Math.max(
69
+ MIN_BAR_PERCENTAGE,
70
+ Math.min(MAX_PERCENTAGE, (containerWidth / contentWidth) * MAX_PERCENTAGE)
71
+ )
72
+ }
73
+
74
+ const calculateThumbPosition = ({ scrollOffset, scrollMax, thumbWidthPercent }) => {
75
+ if (scrollMax <= INITIAL_DIMENSION) return INITIAL_DIMENSION
76
+ return (scrollOffset / scrollMax) * (MAX_PERCENTAGE - thumbWidthPercent)
77
+ }
78
+
79
+ const getScrollableNode = (ref) => ref.current?.getScrollableNode?.() ?? null
80
+
81
+ /**
82
+ * The `Scroll` component provides a continuous horizontal scrollable container with a progress
83
+ * bar indicator. Items are displayed in a flow layout where all items share the width of the
84
+ * widest item and the height of the tallest item.
85
+ *
86
+ * ## Props
87
+ *
88
+ * - Use `children` to pass `ScrollItem` components
89
+ * - Use `accessibilityLabel` to provide a meaningful label for the scrollable region
90
+ * - Use `copy` to select English or French for accessible labels
91
+ * - Use `dictionary` to override the default translations
92
+ *
93
+ * ## Usability and A11y guidelines
94
+ *
95
+ * - The scroll container is keyboard-focusable with `tabIndex="0"`
96
+ * - Each `ScrollItem` is individually focusable via Tab navigation
97
+ * - Use left and right arrow keys to scroll by one item width
98
+ * - Press Space bar to scroll right
99
+ * - When tabbing through items, the scroll follows focus to keep the active item visible
100
+ * - Screen readers announce the component as a region with a descriptive label
101
+ * - Touch and swipe gestures are supported on mobile and mobile web
102
+ *
103
+ */
104
+ const Scroll = React.forwardRef(
105
+ (
106
+ {
107
+ children,
108
+ tokens,
109
+ variant,
110
+ copy,
111
+ dictionary: customDictionary = dictionary,
112
+ accessibilityLabel,
113
+ ...rest
114
+ },
115
+ ref
116
+ ) => {
117
+ const getCopy = useCopy({ dictionary: customDictionary, copy })
118
+ const resolvedAccessibilityLabel = accessibilityLabel || getCopy('accessibilityLabel')
119
+ const getTokens = useThemeTokensCallback(COMPONENT_NAME, tokens, variant)
120
+ const defaultTokens = React.useMemo(() => getTokens(), [getTokens])
121
+ const descriptionId = useUniqueId(SCROLL_DESCRIPTION_PREFIX)
122
+ const scrollRef = React.useRef(null)
123
+ const pressableRef = React.useRef(null)
124
+ const scrollContainerRef = React.useRef(null)
125
+ const scrollMaxRef = React.useRef(INITIAL_DIMENSION)
126
+ const scrollOffsetRef = React.useRef(INITIAL_DIMENSION)
127
+ const thumbWidthPercentRef = React.useRef(MAX_PERCENTAGE)
128
+ const thumbRef = React.useRef(null)
129
+ const isDraggingRef = React.useRef(false)
130
+ const dragStartRef = React.useRef({
131
+ mouseX: INITIAL_DIMENSION,
132
+ scrollOffset: INITIAL_DIMENSION
133
+ })
134
+
135
+ const [scrollOffset, setScrollOffset] = React.useState(INITIAL_DIMENSION)
136
+ const [containerWidth, setContainerWidth] = React.useState(INITIAL_DIMENSION)
137
+ const [contentWidth, setContentWidth] = React.useState(INITIAL_DIMENSION)
138
+ const [itemWidth, setItemWidth] = React.useState(INITIAL_DIMENSION)
139
+ const [itemHeight, setItemHeight] = React.useState(INITIAL_DIMENSION)
140
+
141
+ const { itemGap } = defaultTokens
142
+ const scrollMax = Math.max(INITIAL_DIMENSION, contentWidth - containerWidth)
143
+
144
+ React.useEffect(() => {
145
+ scrollMaxRef.current = scrollMax
146
+ }, [scrollMax])
147
+
148
+ const thumbWidthPercent = calculateThumbWidth({ containerWidth, contentWidth })
149
+ thumbWidthPercentRef.current = thumbWidthPercent
150
+ const thumbPositionPercent = calculateThumbPosition({
151
+ scrollOffset,
152
+ scrollMax,
153
+ thumbWidthPercent
154
+ })
155
+
156
+ const thumbWrapperStyle = React.useMemo(
157
+ () => [
158
+ staticStyles.thumbWrapper,
159
+ selectThumbWrapperStyles({ thumbWidthPercent, thumbPositionPercent }),
160
+ Platform.OS === 'web' ? staticStyles.thumbCursor : null
161
+ ],
162
+ [thumbWidthPercent, thumbPositionPercent]
163
+ )
164
+
165
+ const handleScroll = React.useCallback(
166
+ ({
167
+ nativeEvent: {
168
+ contentOffset: { x }
169
+ }
170
+ }) => {
171
+ scrollOffsetRef.current = x
172
+ setScrollOffset(x)
173
+ },
174
+ []
175
+ )
176
+
177
+ const handleContainerLayout = React.useCallback(
178
+ ({
179
+ nativeEvent: {
180
+ layout: { width }
181
+ }
182
+ }) => {
183
+ setContainerWidth(width)
184
+ },
185
+ []
186
+ )
187
+
188
+ const handleContentWidth = React.useCallback((width) => {
189
+ setContentWidth(width)
190
+ }, [])
191
+
192
+ const handleItemLayout = React.useCallback((width, height) => {
193
+ setItemWidth((prev) => Math.max(prev, width))
194
+ setItemHeight((prev) => Math.max(prev, height))
195
+ }, [])
196
+
197
+ React.useEffect(() => {
198
+ if (Platform.OS !== 'web') return undefined
199
+ const element = scrollContainerRef.current
200
+ if (!element) return undefined
201
+ const handleWheel = (event) => {
202
+ event.preventDefault()
203
+ event.stopPropagation()
204
+ const isHorizontal = Math.abs(event.deltaX) > Math.abs(event.deltaY)
205
+ const delta = isHorizontal ? event.deltaX : event.deltaY
206
+ const scrollableNode = getScrollableNode(scrollRef)
207
+ if (scrollableNode) {
208
+ const maxScroll = scrollMaxRef.current
209
+ const newScrollLeft = Math.max(
210
+ INITIAL_DIMENSION,
211
+ Math.min(maxScroll, scrollableNode.scrollLeft + delta)
212
+ )
213
+ scrollableNode.scrollLeft = newScrollLeft
214
+ scrollOffsetRef.current = newScrollLeft
215
+ setScrollOffset(newScrollLeft)
216
+ }
217
+ }
218
+ element.addEventListener('wheel', handleWheel, { passive: false })
219
+ return () => element.removeEventListener('wheel', handleWheel)
220
+ }, [])
221
+
222
+ React.useEffect(() => {
223
+ if (Platform.OS !== 'web') return undefined
224
+ const element = scrollContainerRef.current
225
+ if (!element) return undefined
226
+
227
+ const handleFocusIn = (event) => {
228
+ const scrollableNode = getScrollableNode(scrollRef)
229
+ if (!scrollableNode) return
230
+ const focusedItem = event.target.closest(SCROLL_ITEM_SELECTOR)
231
+ if (!focusedItem) return
232
+ const itemLeft = focusedItem.offsetLeft
233
+ const itemRight = itemLeft + focusedItem.offsetWidth
234
+ const visibleLeft = scrollableNode.scrollLeft
235
+ const currentContainerWidth = containerWidth
236
+ const visibleRight = visibleLeft + currentContainerWidth
237
+ if (itemRight > visibleRight) {
238
+ scrollRef.current.scrollTo({ x: itemRight - currentContainerWidth, animated: true })
239
+ } else if (itemLeft < visibleLeft) {
240
+ scrollRef.current.scrollTo({ x: itemLeft, animated: true })
241
+ }
242
+ }
243
+
244
+ element.addEventListener('focusin', handleFocusIn)
245
+ return () => element.removeEventListener('focusin', handleFocusIn)
246
+ }, [containerWidth])
247
+
248
+ const hasScrollbar = scrollMax > INITIAL_DIMENSION
249
+
250
+ React.useEffect(() => {
251
+ if (Platform.OS !== 'web' || !hasScrollbar) return undefined
252
+ const thumbElement = thumbRef.current
253
+ if (!thumbElement) return undefined
254
+
255
+ const handleMouseDown = (event) => {
256
+ event.preventDefault()
257
+ event.stopPropagation()
258
+ isDraggingRef.current = true
259
+ dragStartRef.current = {
260
+ mouseX: event.clientX,
261
+ scrollOffset: getScrollableNode(scrollRef)?.scrollLeft ?? INITIAL_DIMENSION
262
+ }
263
+ thumbElement.style.cursor = 'grabbing'
264
+ document.body.style.cursor = 'grabbing'
265
+ document.body.style.userSelect = 'none'
266
+ }
267
+
268
+ const handleMouseMove = (event) => {
269
+ if (!isDraggingRef.current) return
270
+ const trackElement = pressableRef.current
271
+ if (!trackElement) return
272
+ const trackWidth = trackElement.offsetWidth
273
+ const thumbPixelWidth = (thumbWidthPercentRef.current / MAX_PERCENTAGE) * trackWidth
274
+ const availableTrackPixels = trackWidth - thumbPixelWidth
275
+ if (availableTrackPixels <= INITIAL_DIMENSION) return
276
+ const mouseDelta = event.clientX - dragStartRef.current.mouseX
277
+ const scrollDelta = (mouseDelta / availableTrackPixels) * scrollMaxRef.current
278
+ const targetScroll = Math.max(
279
+ INITIAL_DIMENSION,
280
+ Math.min(scrollMaxRef.current, dragStartRef.current.scrollOffset + scrollDelta)
281
+ )
282
+ const scrollableNode = getScrollableNode(scrollRef)
283
+ if (scrollableNode) {
284
+ scrollableNode.scrollLeft = targetScroll
285
+ scrollOffsetRef.current = targetScroll
286
+ setScrollOffset(targetScroll)
287
+ }
288
+ }
289
+
290
+ const handleMouseUp = () => {
291
+ if (!isDraggingRef.current) return
292
+ isDraggingRef.current = false
293
+ thumbElement.style.cursor = ''
294
+ document.body.style.cursor = ''
295
+ document.body.style.userSelect = ''
296
+ }
297
+
298
+ thumbElement.addEventListener('mousedown', handleMouseDown)
299
+ document.addEventListener('mousemove', handleMouseMove)
300
+ document.addEventListener('mouseup', handleMouseUp)
301
+
302
+ return () => {
303
+ thumbElement.removeEventListener('mousedown', handleMouseDown)
304
+ document.removeEventListener('mousemove', handleMouseMove)
305
+ document.removeEventListener('mouseup', handleMouseUp)
306
+ }
307
+ }, [hasScrollbar])
308
+
309
+ const handleKeyDown = React.useCallback(
310
+ (event) => {
311
+ if (event.key === KEY_ARROW_RIGHT || event.key === KEY_SPACE) {
312
+ event.preventDefault()
313
+ if (scrollRef.current) {
314
+ scrollRef.current.scrollTo({
315
+ x: scrollOffsetRef.current + itemWidth,
316
+ animated: true
317
+ })
318
+ }
319
+ } else if (event.key === KEY_ARROW_LEFT) {
320
+ event.preventDefault()
321
+ if (scrollRef.current) {
322
+ scrollRef.current.scrollTo({
323
+ x: Math.max(INITIAL_DIMENSION, scrollOffsetRef.current - itemWidth),
324
+ animated: true
325
+ })
326
+ }
327
+ }
328
+ },
329
+ [itemWidth]
330
+ )
331
+
332
+ const handleProgressPress = React.useCallback(
333
+ (event) => {
334
+ if (scrollMax <= INITIAL_DIMENSION || isDraggingRef.current) return
335
+ if (Platform.OS === 'web' && pressableRef.current) {
336
+ const { offsetX } = event.nativeEvent
337
+ const { offsetWidth } = pressableRef.current
338
+ const thumbPixelWidth = (thumbWidthPercent / MAX_PERCENTAGE) * offsetWidth
339
+ const availableTrack = offsetWidth - thumbPixelWidth
340
+ if (availableTrack <= INITIAL_DIMENSION) return
341
+ const clickPosition = Math.max(
342
+ INITIAL_DIMENSION,
343
+ Math.min(availableTrack, offsetX - thumbPixelWidth / HALF_DIVISOR)
344
+ )
345
+ const targetX = (clickPosition / availableTrack) * scrollMax
346
+ if (scrollRef.current) {
347
+ scrollRef.current.scrollTo({ x: targetX, animated: true })
348
+ }
349
+ } else if (pressableRef.current) {
350
+ const { locationX } = event.nativeEvent
351
+ pressableRef.current.measure((_x, _y, width) => {
352
+ const thumbPixelWidth = (thumbWidthPercent / MAX_PERCENTAGE) * width
353
+ const availableTrack = width - thumbPixelWidth
354
+ if (availableTrack <= INITIAL_DIMENSION) return
355
+ const clickPosition = Math.max(
356
+ INITIAL_DIMENSION,
357
+ Math.min(availableTrack, locationX - thumbPixelWidth / HALF_DIVISOR)
358
+ )
359
+ const targetX = (clickPosition / availableTrack) * scrollMax
360
+ if (scrollRef.current) {
361
+ scrollRef.current.scrollTo({ x: targetX, animated: true })
362
+ }
363
+ })
364
+ }
365
+ },
366
+ [scrollMax, thumbWidthPercent]
367
+ )
368
+
369
+ const handleContainerRef = React.useCallback(
370
+ (node) => {
371
+ scrollContainerRef.current = node
372
+ if (typeof ref === 'function') ref(node)
373
+ else if (ref) Object.assign(ref, { current: node })
374
+ },
375
+ [ref]
376
+ )
377
+
378
+ return (
379
+ <View
380
+ ref={handleContainerRef}
381
+ style={staticStyles.container}
382
+ {...selectContainerA11yProps({
383
+ accessibilityLabel: resolvedAccessibilityLabel,
384
+ descriptionId,
385
+ handleKeyDown
386
+ })}
387
+ {...selectProps(rest)}
388
+ >
389
+ <A11yText text={getCopy('instructionText')} nativeID={descriptionId} />
390
+ <View style={staticStyles.scrollContainer}>
391
+ <ScrollViewEnd
392
+ ref={scrollRef}
393
+ horizontal
394
+ onScrollEnd={handleScroll}
395
+ onScroll={handleScroll}
396
+ onContentSizeChange={handleContentWidth}
397
+ onLayout={handleContainerLayout}
398
+ showsHorizontalScrollIndicator={false}
399
+ contentContainerStyle={[staticStyles.scrollContent, selectScrollContentStyle(itemGap)]}
400
+ >
401
+ <ScrollProvider
402
+ itemWidth={itemWidth}
403
+ itemHeight={itemHeight}
404
+ onItemLayout={handleItemLayout}
405
+ >
406
+ {children}
407
+ </ScrollProvider>
408
+ </ScrollViewEnd>
409
+ </View>
410
+ {scrollMax > INITIAL_DIMENSION && (
411
+ <Pressable
412
+ ref={pressableRef}
413
+ style={[staticStyles.pressable, selectPressableStyles(defaultTokens)]}
414
+ onPress={handleProgressPress}
415
+ >
416
+ {(pressableState) => (
417
+ <ScrollProgress
418
+ ref={thumbRef}
419
+ pressableState={pressableState}
420
+ getTokens={getTokens}
421
+ thumbWrapperStyle={thumbWrapperStyle}
422
+ getCopy={getCopy}
423
+ />
424
+ )}
425
+ </Pressable>
426
+ )}
427
+ </View>
428
+ )
429
+ }
430
+ )
431
+
432
+ Scroll.displayName = 'Scroll'
433
+
434
+ Scroll.propTypes = {
435
+ ...selectedSystemPropTypes,
436
+ /**
437
+ * Scroll items to display. Should be `ScrollItem` components.
438
+ */
439
+ children: PropTypes.node.isRequired,
440
+ /**
441
+ * Theme tokens for customizing the Scroll appearance
442
+ */
443
+ tokens: getTokensPropType('Scroll'),
444
+ /**
445
+ * Variant for the Scroll component
446
+ */
447
+ variant: variantProp.propType,
448
+ /**
449
+ * Select English or French copy for accessible labels.
450
+ */
451
+ copy: copyPropTypes,
452
+ /**
453
+ * Override the default dictionary by passing the complete dictionary object for `en` and `fr`.
454
+ */
455
+ dictionary: PropTypes.shape({
456
+ en: PropTypes.object,
457
+ fr: PropTypes.object
458
+ }),
459
+ /**
460
+ * Accessibility label for the scrollable region
461
+ */
462
+ accessibilityLabel: PropTypes.string
463
+ }
464
+
465
+ export default Scroll
466
+
467
+ const staticStyles = StyleSheet.create({
468
+ container: {
469
+ width: '100%'
470
+ },
471
+ scrollContainer: {
472
+ width: '100%'
473
+ },
474
+ pressable: {
475
+ justifyContent: 'center',
476
+ width: '100%'
477
+ },
478
+ scrollContent: {
479
+ alignSelf: 'flex-start',
480
+ flexGrow: 1
481
+ },
482
+ thumbWrapper: {
483
+ height: '100%'
484
+ },
485
+ thumbCursor: {
486
+ cursor: 'grab'
487
+ }
488
+ })
@@ -0,0 +1,41 @@
1
+ import React from 'react'
2
+ import PropTypes from 'prop-types'
3
+
4
+ const ScrollContext = React.createContext()
5
+
6
+ const ScrollProvider = ({ children, itemWidth, itemHeight, onItemLayout }) => {
7
+ const value = React.useMemo(
8
+ () => ({ itemWidth, itemHeight, onItemLayout }),
9
+ [itemWidth, itemHeight, onItemLayout]
10
+ )
11
+ return <ScrollContext.Provider value={value}>{children}</ScrollContext.Provider>
12
+ }
13
+
14
+ function useScroll() {
15
+ const context = React.useContext(ScrollContext)
16
+ if (context === undefined) {
17
+ throw new Error(`'useScroll' must be used within a 'ScrollProvider'`)
18
+ }
19
+ return context
20
+ }
21
+
22
+ ScrollProvider.propTypes = {
23
+ /**
24
+ * Content to be rendered within the scroll context provider.
25
+ */
26
+ children: PropTypes.node.isRequired,
27
+ /**
28
+ * Width of each scroll item in pixels.
29
+ */
30
+ itemWidth: PropTypes.number.isRequired,
31
+ /**
32
+ * Height of each scroll item in pixels.
33
+ */
34
+ itemHeight: PropTypes.number.isRequired,
35
+ /**
36
+ * Callback invoked when a scroll item layout is measured.
37
+ */
38
+ onItemLayout: PropTypes.func.isRequired
39
+ }
40
+
41
+ export { ScrollProvider, useScroll }
@@ -0,0 +1,89 @@
1
+ import React from 'react'
2
+ import PropTypes from 'prop-types'
3
+ import { Platform, View } from 'react-native'
4
+ import {
5
+ layoutTags,
6
+ getA11yPropsFromHtmlTag,
7
+ selectSystemProps,
8
+ a11yProps,
9
+ viewProps
10
+ } from '../utils'
11
+ import { useScroll } from './ScrollContext'
12
+
13
+ const [selectProps, selectedSystemPropTypes] = selectSystemProps([a11yProps, viewProps])
14
+
15
+ const DEFAULT_TAG = 'li'
16
+ const DATA_SCROLL_ITEM = 'scrollItem'
17
+ const INITIAL_DIMENSION = 0
18
+ const TAB_INDEX = 0
19
+
20
+ const selectContainerStyle = ({ itemWidth, itemHeight }) => {
21
+ const style = {}
22
+ if (itemWidth > INITIAL_DIMENSION) {
23
+ style.width = itemWidth
24
+ }
25
+ if (itemHeight > INITIAL_DIMENSION) {
26
+ style.height = itemHeight
27
+ }
28
+ return style
29
+ }
30
+
31
+ /**
32
+ * The `ScrollItem` component wraps individual items within a `Scroll` container. Each item
33
+ * is individually focusable and reports its natural dimensions so the parent can apply uniform
34
+ * sizing across all items.
35
+ *
36
+ * ## Props
37
+ *
38
+ * - Use `children` to provide the content of the scroll item
39
+ * - Use `tag` to set the HTML tag of the outer container (defaults to `'li'`)
40
+ *
41
+ */
42
+ const ScrollItem = React.forwardRef(({ children, tag = DEFAULT_TAG, ...rest }, ref) => {
43
+ const { itemWidth, itemHeight, onItemLayout } = useScroll()
44
+
45
+ const handleLayout = React.useCallback(
46
+ ({
47
+ nativeEvent: {
48
+ layout: { width, height }
49
+ }
50
+ }) => {
51
+ onItemLayout?.(width, height)
52
+ },
53
+ [onItemLayout]
54
+ )
55
+
56
+ const selectedProps = selectProps({
57
+ ...rest,
58
+ ...getA11yPropsFromHtmlTag(tag, rest.accessibilityRole)
59
+ })
60
+
61
+ return (
62
+ <View
63
+ ref={ref}
64
+ style={selectContainerStyle({ itemWidth, itemHeight })}
65
+ onLayout={handleLayout}
66
+ dataSet={{ [DATA_SCROLL_ITEM]: true }}
67
+ {...(Platform.OS === 'web' ? { tabIndex: TAB_INDEX } : { focusable: true })}
68
+ {...selectedProps}
69
+ >
70
+ {children}
71
+ </View>
72
+ )
73
+ })
74
+
75
+ ScrollItem.displayName = 'ScrollItem'
76
+
77
+ ScrollItem.propTypes = {
78
+ ...selectedSystemPropTypes,
79
+ /**
80
+ * Content of the scroll item
81
+ */
82
+ children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
83
+ /**
84
+ * Sets the HTML tag of the outer container. Defaults to `'li'`.
85
+ */
86
+ tag: PropTypes.oneOf(layoutTags)
87
+ }
88
+
89
+ export default React.memo(ScrollItem)
@@ -0,0 +1,75 @@
1
+ import React from 'react'
2
+ import PropTypes from 'prop-types'
3
+ import { View } from 'react-native'
4
+
5
+ import { resolvePressableState, useAnimatedValue } from '../utils'
6
+ import Progress from '../Progress'
7
+
8
+ const MAX_PERCENTAGE = 100
9
+ const BORDER_RADIUS_DIVISOR = 2
10
+
11
+ const staticProgressTokens = {
12
+ borderWidth: 0,
13
+ borderColor: 'transparent'
14
+ }
15
+
16
+ const staticProgressBarTokens = {
17
+ gradient: null,
18
+ outlineWidth: 0
19
+ }
20
+
21
+ const selectProgressTokens = ({ progressTrackHeight, progressBarHeight, progressTrackColor }) => ({
22
+ height: progressTrackHeight,
23
+ barHeight: progressBarHeight,
24
+ backgroundColor: progressTrackColor,
25
+ borderRadius: progressTrackHeight / BORDER_RADIUS_DIVISOR
26
+ })
27
+
28
+ const selectBarTokens = ({ progressBarColor, progressBarHeight }) => ({
29
+ backgroundColor: progressBarColor,
30
+ borderRadius: progressBarHeight / BORDER_RADIUS_DIVISOR
31
+ })
32
+
33
+ const ScrollProgress = React.forwardRef(
34
+ ({ pressableState, getTokens, thumbWrapperStyle, getCopy }, ref) => {
35
+ const state = resolvePressableState(pressableState)
36
+ const themeTokens = getTokens(state)
37
+ const animatedBarHeight = useAnimatedValue(themeTokens.progressBarHeight)
38
+ const animatedTokens = { ...themeTokens, progressBarHeight: animatedBarHeight }
39
+
40
+ return (
41
+ <Progress tokens={{ ...staticProgressTokens, ...selectProgressTokens(animatedTokens) }}>
42
+ <View ref={ref} style={thumbWrapperStyle}>
43
+ <Progress.Bar
44
+ percentage={MAX_PERCENTAGE}
45
+ tokens={{ ...staticProgressBarTokens, ...selectBarTokens(animatedTokens) }}
46
+ accessibilityLabel={getCopy('progressBarLabel')}
47
+ />
48
+ </View>
49
+ </Progress>
50
+ )
51
+ }
52
+ )
53
+
54
+ ScrollProgress.displayName = 'ScrollProgress'
55
+
56
+ ScrollProgress.propTypes = {
57
+ /**
58
+ * State object from Pressable's render callback containing hovered, pressed, and focused states
59
+ */
60
+ pressableState: PropTypes.object.isRequired,
61
+ /**
62
+ * Theme token resolver callback from useThemeTokensCallback that accepts appearance state
63
+ */
64
+ getTokens: PropTypes.func.isRequired,
65
+ /**
66
+ * Style array for the thumb wrapper containing static and dynamic position styles
67
+ */
68
+ thumbWrapperStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.array]).isRequired,
69
+ /**
70
+ * Copy resolver function from useCopy for retrieving localized strings
71
+ */
72
+ getCopy: PropTypes.func.isRequired
73
+ }
74
+
75
+ export default ScrollProgress
@@ -0,0 +1,12 @@
1
+ export default {
2
+ en: {
3
+ accessibilityLabel: 'Scrollable content',
4
+ instructionText: 'Use Tab, arrow keys, or space bar to navigate',
5
+ progressBarLabel: 'Scroll position'
6
+ },
7
+ fr: {
8
+ accessibilityLabel: 'Contenu défilable',
9
+ instructionText: "Utilisez Tab, les touches fléchées ou la barre d'espace pour naviguer",
10
+ progressBarLabel: 'Position de défilement'
11
+ }
12
+ }