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