@tamagui/slider 1.0.1-beta.59 → 1.0.1-beta.60

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tamagui/slider",
3
- "version": "1.0.1-beta.59",
3
+ "version": "1.0.1-beta.60",
4
4
  "sideEffects": true,
5
5
  "source": "src/index.ts",
6
6
  "types": "./types/index.d.ts",
@@ -8,6 +8,7 @@
8
8
  "module": "dist/esm",
9
9
  "module:jsx": "dist/jsx",
10
10
  "files": [
11
+ "src",
11
12
  "types",
12
13
  "dist"
13
14
  ],
@@ -18,12 +19,12 @@
18
19
  "clean:build": "tamagui-build clean:build"
19
20
  },
20
21
  "dependencies": {
21
- "@tamagui/compose-refs": "^1.0.1-beta.59",
22
- "@tamagui/core": "^1.0.1-beta.59",
23
- "@tamagui/create-context": "^1.0.1-beta.59",
24
- "@tamagui/stacks": "^1.0.1-beta.59",
25
- "@tamagui/use-controllable-state": "^1.0.1-beta.59",
26
- "@tamagui/use-direction": "^1.0.1-beta.59"
22
+ "@tamagui/compose-refs": "^1.0.1-beta.60",
23
+ "@tamagui/core": "^1.0.1-beta.60",
24
+ "@tamagui/create-context": "^1.0.1-beta.60",
25
+ "@tamagui/stacks": "^1.0.1-beta.60",
26
+ "@tamagui/use-controllable-state": "^1.0.1-beta.60",
27
+ "@tamagui/use-direction": "^1.0.1-beta.60"
27
28
  },
28
29
  "peerDependencies": {
29
30
  "react": "*",
@@ -31,7 +32,7 @@
31
32
  "react-native": "*"
32
33
  },
33
34
  "devDependencies": {
34
- "@tamagui/build": "^1.0.1-beta.59",
35
+ "@tamagui/build": "^1.0.1-beta.60",
35
36
  "@types/react-native": "^0.67.3",
36
37
  "react": "*",
37
38
  "react-dom": "*",
package/src/Slider.tsx ADDED
@@ -0,0 +1,584 @@
1
+ // forked from radix-ui
2
+
3
+ import { useComposedRefs } from '@tamagui/compose-refs'
4
+ import { getButtonSize, getSize, isWeb, styled, withStaticProperties } from '@tamagui/core'
5
+ import { clamp, composeEventHandlers } from '@tamagui/helpers'
6
+ import { SizableStackProps, ThemeableStack, YStackProps } from '@tamagui/stacks'
7
+ import { useControllableState } from '@tamagui/use-controllable-state'
8
+ import { useDirection } from '@tamagui/use-direction'
9
+ import * as React from 'react'
10
+ import { LayoutRectangle, View } from 'react-native'
11
+
12
+ import {
13
+ ARROW_KEYS,
14
+ BACK_KEYS,
15
+ PAGE_KEYS,
16
+ SLIDER_NAME,
17
+ SliderOrientationProvider,
18
+ SliderProvider,
19
+ useSliderContext,
20
+ useSliderOrientationContext,
21
+ } from './constants'
22
+ import {
23
+ convertValueToPercentage,
24
+ getClosestValueIndex,
25
+ getDecimalCount,
26
+ getLabel,
27
+ getNextSortedValues,
28
+ getThumbInBoundsOffset,
29
+ hasMinStepsBetweenValues,
30
+ linearScale,
31
+ roundValue,
32
+ } from './helpers'
33
+ import { SliderFrame, SliderImpl } from './SliderImpl'
34
+ import {
35
+ ScopedProps,
36
+ SliderContextValue,
37
+ SliderHorizontalProps,
38
+ SliderImplElement,
39
+ SliderProps,
40
+ SliderTrackProps,
41
+ SliderVerticalProps,
42
+ } from './types'
43
+
44
+ /* -------------------------------------------------------------------------------------------------
45
+ * SliderHorizontal
46
+ * -----------------------------------------------------------------------------------------------*/
47
+
48
+ type SliderHorizontalElement = SliderImplElement
49
+
50
+ const SliderHorizontal = React.forwardRef<SliderHorizontalElement, SliderHorizontalProps>(
51
+ (props: ScopedProps<SliderHorizontalProps>, forwardedRef) => {
52
+ const { min, max, dir, onSlideStart, onSlideMove, onStepKeyDown, ...sliderProps } = props
53
+ const direction = useDirection(dir)
54
+ const isDirectionLTR = direction === 'ltr'
55
+ const layoutRef = React.useRef<LayoutRectangle | null>(null)
56
+ const [size, setSize] = React.useState(0)
57
+
58
+ function getValueFromPointer(pointerPosition: number) {
59
+ const layout = layoutRef.current
60
+ if (!layout) return
61
+ const input: [number, number] = [0, layout.width]
62
+ const output: [number, number] = isDirectionLTR ? [min, max] : [max, min]
63
+ const value = linearScale(input, output)
64
+ return value(pointerPosition)
65
+ }
66
+
67
+ return (
68
+ <SliderOrientationProvider
69
+ scope={props.__scopeSlider}
70
+ startEdge={isDirectionLTR ? 'left' : 'right'}
71
+ endEdge={isDirectionLTR ? 'right' : 'left'}
72
+ direction={isDirectionLTR ? 1 : -1}
73
+ sizeProp="width"
74
+ size={size}
75
+ >
76
+ <SliderImpl
77
+ ref={forwardedRef}
78
+ dir={direction}
79
+ {...sliderProps}
80
+ orientation="horizontal"
81
+ onLayout={(e) => {
82
+ const layout = e.nativeEvent.layout
83
+ layoutRef.current = layout
84
+ setSize(layout.height)
85
+ }}
86
+ onSlideStart={(event, target) => {
87
+ const value = getValueFromPointer(event.nativeEvent.locationX)
88
+ if (value) {
89
+ onSlideStart?.(value, target)
90
+ }
91
+ }}
92
+ onSlideMove={(event) => {
93
+ const value = getValueFromPointer(event.nativeEvent.locationX)
94
+ if (value) {
95
+ onSlideMove?.(value)
96
+ }
97
+ }}
98
+ onSlideEnd={() => {}}
99
+ onStepKeyDown={(event) => {
100
+ const isBackKey = BACK_KEYS[direction].includes(event.key)
101
+ onStepKeyDown?.({ event, direction: isBackKey ? -1 : 1 })
102
+ }}
103
+ />
104
+ </SliderOrientationProvider>
105
+ )
106
+ }
107
+ )
108
+
109
+ /* -------------------------------------------------------------------------------------------------
110
+ * SliderVertical
111
+ * -----------------------------------------------------------------------------------------------*/
112
+
113
+ type SliderVerticalElement = SliderImplElement
114
+
115
+ const SliderVertical = React.forwardRef<SliderVerticalElement, SliderVerticalProps>(
116
+ (props: ScopedProps<SliderVerticalProps>, forwardedRef) => {
117
+ const { min, max, onSlideStart, onSlideMove, onStepKeyDown, ...sliderProps } = props
118
+ const layoutRef = React.useRef<LayoutRectangle | null>(null)
119
+ const [size, setSize] = React.useState(0)
120
+
121
+ function getValueFromPointer(pointerPosition: number) {
122
+ const layout = layoutRef.current
123
+ if (!layout) return
124
+ const input: [number, number] = [0, layout.height]
125
+ const output: [number, number] = [max, min]
126
+ const value = linearScale(input, output)
127
+ return value(pointerPosition)
128
+ }
129
+
130
+ return (
131
+ <SliderOrientationProvider
132
+ scope={props.__scopeSlider}
133
+ startEdge="bottom"
134
+ endEdge="top"
135
+ sizeProp="height"
136
+ size={size}
137
+ direction={1}
138
+ >
139
+ <SliderImpl
140
+ ref={forwardedRef}
141
+ {...sliderProps}
142
+ orientation="vertical"
143
+ onLayout={(e) => {
144
+ const layout = e.nativeEvent.layout
145
+ layoutRef.current = layout
146
+ setSize(layout.height)
147
+ }}
148
+ onSlideStart={(event, target) => {
149
+ const value = getValueFromPointer(event.nativeEvent.locationY)
150
+ if (value) {
151
+ onSlideStart?.(value, target)
152
+ }
153
+ }}
154
+ onSlideMove={(event) => {
155
+ const value = getValueFromPointer(event.nativeEvent.locationY)
156
+ if (value) {
157
+ onSlideMove?.(value)
158
+ }
159
+ }}
160
+ onSlideEnd={() => {}}
161
+ onStepKeyDown={(event) => {
162
+ const isBackKey = BACK_KEYS.ltr.includes(event.key)
163
+ onStepKeyDown?.({ event, direction: isBackKey ? -1 : 1 })
164
+ }}
165
+ />
166
+ </SliderOrientationProvider>
167
+ )
168
+ }
169
+ )
170
+
171
+ /* -------------------------------------------------------------------------------------------------
172
+ * SliderTrack
173
+ * -----------------------------------------------------------------------------------------------*/
174
+
175
+ const TRACK_NAME = 'SliderTrack'
176
+
177
+ type SliderTrackElement = HTMLElement | View
178
+
179
+ const SliderTrackFrame = styled(SliderFrame, {
180
+ name: 'SliderTrack',
181
+ height: '100%',
182
+ width: '100%',
183
+ backgroundColor: '$background',
184
+ position: 'relative',
185
+ borderRadius: 100_000,
186
+ overflow: 'hidden',
187
+ })
188
+
189
+ const SliderTrack = React.forwardRef<SliderTrackElement, SliderTrackProps>(
190
+ (props: ScopedProps<SliderTrackProps>, forwardedRef) => {
191
+ const { __scopeSlider, ...trackProps } = props
192
+ const context = useSliderContext(TRACK_NAME, __scopeSlider)
193
+ return (
194
+ <SliderTrackFrame
195
+ data-disabled={context.disabled ? '' : undefined}
196
+ data-orientation={context.orientation}
197
+ orientation={context.orientation}
198
+ size={context.size}
199
+ {...trackProps}
200
+ ref={forwardedRef}
201
+ />
202
+ )
203
+ }
204
+ )
205
+
206
+ SliderTrack.displayName = TRACK_NAME
207
+
208
+ /* -------------------------------------------------------------------------------------------------
209
+ * SliderTrackActive
210
+ * -----------------------------------------------------------------------------------------------*/
211
+
212
+ const RANGE_NAME = 'SliderTrackActive'
213
+
214
+ type SliderTrackActiveElement = HTMLElement | View
215
+ interface SliderTrackActiveProps extends YStackProps {}
216
+
217
+ const SliderTrackActiveFrame = styled(SliderFrame, {
218
+ name: 'SliderTrackActive',
219
+ backgroundColor: '$background',
220
+ position: 'absolute',
221
+ })
222
+
223
+ const SliderTrackActive = React.forwardRef<SliderTrackActiveElement, SliderTrackActiveProps>(
224
+ (props: ScopedProps<SliderTrackActiveProps>, forwardedRef) => {
225
+ const { __scopeSlider, ...rangeProps } = props
226
+ const context = useSliderContext(RANGE_NAME, __scopeSlider)
227
+ const orientation = useSliderOrientationContext(RANGE_NAME, __scopeSlider)
228
+ const ref = React.useRef<HTMLSpanElement>(null)
229
+ const composedRefs = useComposedRefs(forwardedRef, ref)
230
+ const valuesCount = context.values.length
231
+ const percentages = context.values.map((value) =>
232
+ convertValueToPercentage(value, context.min, context.max)
233
+ )
234
+ const offsetStart = valuesCount > 1 ? Math.min(...percentages) : 0
235
+ const offsetEnd = 100 - Math.max(...percentages)
236
+
237
+ return (
238
+ <SliderTrackActiveFrame
239
+ orientation={context.orientation}
240
+ data-orientation={context.orientation}
241
+ data-disabled={context.disabled ? '' : undefined}
242
+ size={context.size}
243
+ {...rangeProps}
244
+ ref={composedRefs}
245
+ {...{
246
+ [orientation.startEdge]: offsetStart + '%',
247
+ [orientation.endEdge]: offsetEnd + '%',
248
+ }}
249
+ {...(orientation.sizeProp === 'width'
250
+ ? {
251
+ height: '100%',
252
+ }
253
+ : {
254
+ left: 0,
255
+ right: 0,
256
+ })}
257
+ />
258
+ )
259
+ }
260
+ )
261
+
262
+ SliderTrackActive.displayName = RANGE_NAME
263
+
264
+ /* -------------------------------------------------------------------------------------------------
265
+ * SliderThumb
266
+ * -----------------------------------------------------------------------------------------------*/
267
+
268
+ const THUMB_NAME = 'SliderThumb'
269
+
270
+ const SliderThumbFrame = styled(ThemeableStack, {
271
+ name: 'SliderThumb',
272
+ position: 'absolute',
273
+ // TODO not taking up 2
274
+ bordered: 2,
275
+ // OR THIS
276
+ borderWidth: 2,
277
+ backgrounded: true,
278
+ pressable: true,
279
+ focusable: true,
280
+ hoverable: true,
281
+
282
+ variants: {
283
+ size: {
284
+ '...size': (val) => {
285
+ const size = typeof val === 'number' ? val : getSize(val, -1)
286
+ return {
287
+ width: size,
288
+ height: size,
289
+ minWidth: size,
290
+ minHeight: size,
291
+ }
292
+ },
293
+ },
294
+ },
295
+ })
296
+
297
+ type SliderThumbElement = HTMLElement | View
298
+ interface SliderThumbProps extends SizableStackProps {
299
+ index: number
300
+ }
301
+
302
+ const SliderThumb = React.forwardRef<SliderThumbElement, SliderThumbProps>(
303
+ (props: ScopedProps<SliderThumbProps>, forwardedRef) => {
304
+ const { __scopeSlider, index, size: sizeProp, ...thumbProps } = props
305
+ const context = useSliderContext(THUMB_NAME, __scopeSlider)
306
+ const orientation = useSliderOrientationContext(THUMB_NAME, __scopeSlider)
307
+ const [thumb, setThumb] = React.useState<View | HTMLElement | null>(null)
308
+ const composedRefs = useComposedRefs(forwardedRef, (node) => setThumb(node))
309
+
310
+ // We cast because index could be `-1` which would return undefined
311
+ const value = context.values[index] as number | undefined
312
+ const percent =
313
+ value === undefined ? 0 : convertValueToPercentage(value, context.min, context.max)
314
+ const label = getLabel(index, context.values.length)
315
+ const [size, setSize] = React.useState(0)
316
+
317
+ const thumbInBoundsOffset = size
318
+ ? getThumbInBoundsOffset(size, percent, orientation.direction)
319
+ : 0
320
+
321
+ React.useEffect(() => {
322
+ if (thumb) {
323
+ context.thumbs.add(thumb)
324
+ return () => {
325
+ context.thumbs.delete(thumb)
326
+ }
327
+ }
328
+ }, [thumb, context.thumbs])
329
+
330
+ return (
331
+ <SliderThumbFrame
332
+ ref={composedRefs}
333
+ // TODO
334
+ // @ts-ignore
335
+ role="slider"
336
+ aria-label={props['aria-label'] || label}
337
+ aria-valuemin={context.min}
338
+ aria-valuenow={value}
339
+ aria-valuemax={context.max}
340
+ aria-orientation={context.orientation}
341
+ data-orientation={context.orientation}
342
+ data-disabled={context.disabled ? '' : undefined}
343
+ // TODO
344
+ // @ts-ignore
345
+ tabIndex={context.disabled ? undefined : 0}
346
+ {...thumbProps}
347
+ {...(context.orientation === 'horizontal'
348
+ ? {
349
+ x: thumbInBoundsOffset - size / 2,
350
+ y: -size / 2,
351
+ top: '50%',
352
+ ...(size === 0 && {
353
+ top: 'auto',
354
+ bottom: 'auto',
355
+ }),
356
+ }
357
+ : {
358
+ x: -size / 2,
359
+ y: size / 2,
360
+ left: '50%',
361
+ ...(size === 0 && {
362
+ left: 'auto',
363
+ right: 'auto',
364
+ }),
365
+ })}
366
+ size={sizeProp ?? context.size ?? '$4'}
367
+ onLayout={(e) => {
368
+ setSize(e.nativeEvent.layout[orientation.sizeProp])
369
+ }}
370
+ {...{
371
+ [orientation.startEdge]: `${percent}%`,
372
+ }}
373
+ /**
374
+ * There will be no value on initial render while we work out the index so we hide thumbs
375
+ * without a value, otherwise SSR will render them in the wrong position before they
376
+ * snap into the correct position during hydration which would be visually jarring for
377
+ * slower connections.
378
+ */
379
+ // style={value === undefined ? { display: 'none' } : props.style}
380
+ onFocus={composeEventHandlers(props.onFocus, () => {
381
+ context.valueIndexToChangeRef.current = index
382
+ })}
383
+ />
384
+ )
385
+ }
386
+ )
387
+
388
+ SliderThumb.displayName = THUMB_NAME
389
+
390
+ /* -------------------------------------------------------------------------------------------------
391
+ * Slider
392
+ * -----------------------------------------------------------------------------------------------*/
393
+
394
+ type SliderElement = SliderHorizontalElement | SliderVerticalElement
395
+
396
+ const Slider = withStaticProperties(
397
+ React.forwardRef<SliderElement, SliderProps>((props: ScopedProps<SliderProps>, forwardedRef) => {
398
+ const {
399
+ name,
400
+ min = 0,
401
+ max = 100,
402
+ step = 1,
403
+ orientation = 'horizontal',
404
+ disabled = false,
405
+ minStepsBetweenThumbs = 0,
406
+ defaultValue = [min],
407
+ value,
408
+ onValueChange = () => {},
409
+ size: sizeProp,
410
+ ...sliderProps
411
+ } = props
412
+ const sliderRef = React.useRef<SliderImplElement>(null)
413
+ const composedRefs = useComposedRefs(sliderRef, forwardedRef)
414
+ const thumbRefs = React.useRef<SliderContextValue['thumbs']>(new Set())
415
+ const valueIndexToChangeRef = React.useRef<number>(0)
416
+ const isHorizontal = orientation === 'horizontal'
417
+ // We set this to true by default so that events bubble to forms without JS (SSR)
418
+ // TODO
419
+ // const isFormControl = slider ? Boolean(slider.closest('form')) : true
420
+
421
+ const [values = [], setValues] = useControllableState({
422
+ prop: value,
423
+ defaultProp: defaultValue,
424
+ onChange: (value) => {
425
+ if (isWeb) {
426
+ const thumbs = [...thumbRefs.current]
427
+ thumbs[valueIndexToChangeRef.current]?.focus()
428
+ }
429
+ onValueChange(value)
430
+ },
431
+ })
432
+
433
+ if (isWeb) {
434
+ React.useEffect(() => {
435
+ const node = sliderRef.current as HTMLElement
436
+ if (!node) return
437
+ const preventDefault = (e) => {
438
+ e.preventDefault()
439
+ }
440
+ node.addEventListener('touchstart', preventDefault)
441
+ return () => {
442
+ node.removeEventListener('touchstart', preventDefault)
443
+ }
444
+ }, [])
445
+ }
446
+
447
+ function handleSlideMove(value: number) {
448
+ updateValues(value, valueIndexToChangeRef.current)
449
+ }
450
+
451
+ function updateValues(value: number, atIndex: number) {
452
+ const decimalCount = getDecimalCount(step)
453
+ const snapToStep = roundValue(Math.round((value - min) / step) * step + min, decimalCount)
454
+ const nextValue = clamp(snapToStep, [min, max])
455
+ setValues((prevValues = []) => {
456
+ const nextValues = getNextSortedValues(prevValues, nextValue, atIndex)
457
+ if (hasMinStepsBetweenValues(nextValues, minStepsBetweenThumbs * step)) {
458
+ valueIndexToChangeRef.current = nextValues.indexOf(nextValue)
459
+ return String(nextValues) === String(prevValues) ? prevValues : nextValues
460
+ } else {
461
+ return prevValues
462
+ }
463
+ })
464
+ }
465
+
466
+ const SliderOriented = isHorizontal ? SliderHorizontal : SliderVertical
467
+
468
+ return (
469
+ <SliderProvider
470
+ scope={props.__scopeSlider}
471
+ disabled={disabled}
472
+ min={min}
473
+ max={max}
474
+ valueIndexToChangeRef={valueIndexToChangeRef}
475
+ thumbs={thumbRefs.current}
476
+ values={values}
477
+ orientation={orientation}
478
+ size={sizeProp}
479
+ >
480
+ <SliderOriented
481
+ aria-disabled={disabled}
482
+ data-disabled={disabled ? '' : undefined}
483
+ {...sliderProps}
484
+ ref={composedRefs}
485
+ min={min}
486
+ max={max}
487
+ onSlideStart={
488
+ disabled
489
+ ? undefined
490
+ : (value: number, target) => {
491
+ // when starting on the track, move it right away
492
+ // when starting on thumb, dont jump until movemenet as it feels weird
493
+ if (target !== 'thumb') {
494
+ const closestIndex = getClosestValueIndex(values, value)
495
+ updateValues(value, closestIndex)
496
+ }
497
+ }
498
+ }
499
+ onSlideMove={disabled ? undefined : handleSlideMove}
500
+ onHomeKeyDown={() => !disabled && updateValues(min, 0)}
501
+ onEndKeyDown={() => !disabled && updateValues(max, values.length - 1)}
502
+ onStepKeyDown={({ event, direction: stepDirection }) => {
503
+ if (!disabled) {
504
+ const isPageKey = PAGE_KEYS.includes(event.key)
505
+ const isSkipKey = isPageKey || (event.shiftKey && ARROW_KEYS.includes(event.key))
506
+ const multiplier = isSkipKey ? 10 : 1
507
+ const atIndex = valueIndexToChangeRef.current
508
+ const value = values[atIndex]
509
+ const stepInDirection = step * multiplier * stepDirection
510
+ updateValues(value + stepInDirection, atIndex)
511
+ }
512
+ }}
513
+ />
514
+ {/* {isFormControl &&
515
+ values.map((value, index) => (
516
+ <BubbleInput
517
+ key={index}
518
+ name={name ? name + (values.length > 1 ? '[]' : '') : undefined}
519
+ value={value}
520
+ />
521
+ ))} */}
522
+ </SliderProvider>
523
+ )
524
+ }),
525
+ {
526
+ Track: SliderTrack,
527
+ TrackActive: SliderTrackActive,
528
+ Thumb: SliderThumb,
529
+ }
530
+ )
531
+
532
+ Slider.displayName = SLIDER_NAME
533
+
534
+ /* -----------------------------------------------------------------------------------------------*/
535
+
536
+ // TODO
537
+ // const BubbleInput = (props: any) => {
538
+ // const { value, ...inputProps } = props
539
+ // const ref = React.useRef<HTMLInputElement>(null)
540
+ // const prevValue = usePrevious(value)
541
+
542
+ // // Bubble value change to parents (e.g form change event)
543
+ // React.useEffect(() => {
544
+ // const input = ref.current!
545
+ // const inputProto = window.HTMLInputElement.prototype
546
+ // const descriptor = Object.getOwnPropertyDescriptor(inputProto, 'value') as PropertyDescriptor
547
+ // const setValue = descriptor.set
548
+ // if (prevValue !== value && setValue) {
549
+ // const event = new Event('input', { bubbles: true })
550
+ // setValue.call(input, value)
551
+ // input.dispatchEvent(event)
552
+ // }
553
+ // }, [prevValue, value])
554
+
555
+ // /**
556
+ // * We purposefully do not use `type="hidden"` here otherwise forms that
557
+ // * wrap it will not be able to access its value via the FormData API.
558
+ // *
559
+ // * We purposefully do not add the `value` attribute here to allow the value
560
+ // * to be set programatically and bubble to any parent form `onChange` event.
561
+ // * Adding the `value` will cause React to consider the programatic
562
+ // * dispatch a duplicate and it will get swallowed.
563
+ // */
564
+ // return <input style={{ display: 'none' }} {...inputProps} ref={ref} defaultValue={value} />
565
+ // }
566
+
567
+ /* -----------------------------------------------------------------------------------------------*/
568
+
569
+ const Track = SliderTrack
570
+ const Range = SliderTrackActive
571
+ const Thumb = SliderThumb
572
+
573
+ export {
574
+ Slider,
575
+ SliderTrack,
576
+ SliderTrackActive,
577
+ SliderThumb,
578
+ //
579
+ Track,
580
+ Range,
581
+ Thumb,
582
+ }
583
+
584
+ export type { SliderProps, SliderTrackProps, SliderTrackActiveProps, SliderThumbProps }
@@ -0,0 +1,105 @@
1
+ /* -------------------------------------------------------------------------------------------------
2
+ * SliderImpl
3
+ * -----------------------------------------------------------------------------------------------*/
4
+
5
+ import { composeEventHandlers, getSize, getVariableValue, isWeb, styled } from '@tamagui/core'
6
+ import { YStack } from '@tamagui/stacks'
7
+ import * as React from 'react'
8
+
9
+ import { ARROW_KEYS, PAGE_KEYS, SLIDER_NAME, useSliderContext } from './constants'
10
+ import { ScopedProps, SliderImplElement, SliderImplProps } from './types'
11
+
12
+ export const DirectionalYStack = styled(YStack, {
13
+ variants: {
14
+ orientation: {
15
+ horizontal: {},
16
+ vertical: {},
17
+ },
18
+ },
19
+ })
20
+
21
+ export const SliderFrame = styled(DirectionalYStack, {
22
+ position: 'relative',
23
+
24
+ variants: {
25
+ size: (val, extras) => {
26
+ const orientation = extras.props.orientation
27
+ const size = Math.round(getVariableValue(getSize(val)) / 6)
28
+ if (orientation === 'horizontal') {
29
+ return {
30
+ height: size,
31
+ borderRadius: size,
32
+ justifyContent: 'center',
33
+ }
34
+ }
35
+ return {
36
+ width: size,
37
+ borderRadius: size,
38
+ alignItems: 'center',
39
+ }
40
+ },
41
+ },
42
+ })
43
+
44
+ export const SliderImpl = React.forwardRef<SliderImplElement, SliderImplProps>(
45
+ (props: ScopedProps<SliderImplProps>, forwardedRef) => {
46
+ const {
47
+ __scopeSlider,
48
+ onSlideStart,
49
+ onSlideMove,
50
+ onSlideEnd,
51
+ onHomeKeyDown,
52
+ onEndKeyDown,
53
+ onStepKeyDown,
54
+ ...sliderProps
55
+ } = props
56
+ const context = useSliderContext(SLIDER_NAME, __scopeSlider)
57
+ return (
58
+ <SliderFrame
59
+ size="$4"
60
+ {...sliderProps}
61
+ data-orientation={sliderProps.orientation}
62
+ ref={forwardedRef}
63
+ {...(isWeb && {
64
+ onKeyDown: (event) => {
65
+ if (event.key === 'Home') {
66
+ onHomeKeyDown(event)
67
+ // Prevent scrolling to page start
68
+ event.preventDefault()
69
+ } else if (event.key === 'End') {
70
+ onEndKeyDown(event)
71
+ // Prevent scrolling to page end
72
+ event.preventDefault()
73
+ } else if (PAGE_KEYS.concat(ARROW_KEYS).includes(event.key)) {
74
+ onStepKeyDown(event)
75
+ // Prevent scrolling for directional key presses
76
+ event.preventDefault()
77
+ }
78
+ },
79
+ })}
80
+ onStartShouldSetResponder={() => true}
81
+ onResponderGrant={composeEventHandlers(props.onResponderGrant, (event) => {
82
+ const target = event.target as HTMLElement | number
83
+ const isStartingOnThumb = context.thumbs.has(event.target)
84
+ // // Prevent browser focus behaviour because we focus a thumb manually when values change.
85
+ // Touch devices have a delay before focusing so won't focus if touch immediately moves
86
+ // away from target (sliding). We want thumb to focus regardless.
87
+ if (isWeb && target instanceof HTMLElement) {
88
+ if (context.thumbs.has(target)) {
89
+ target.focus()
90
+ }
91
+ }
92
+ onSlideStart(event, isStartingOnThumb ? 'thumb' : 'track')
93
+ })}
94
+ onResponderMove={composeEventHandlers(props.onResponderMove, (event) => {
95
+ // const target = event.target as HTMLElement
96
+ onSlideMove(event)
97
+ })}
98
+ onResponderRelease={composeEventHandlers(props.onResponderRelease, (event) => {
99
+ // const target = event.target as HTMLElement
100
+ onSlideEnd(event)
101
+ })}
102
+ />
103
+ )
104
+ }
105
+ )
@@ -0,0 +1,32 @@
1
+ import { SizeTokens } from '@tamagui/core'
2
+ import { createContextScope } from '@tamagui/create-context'
3
+
4
+ import { Direction, SliderContextValue } from './types'
5
+
6
+ export const SLIDER_NAME = 'Slider'
7
+
8
+ export const [createSliderContext, createSliderScope] = createContextScope(SLIDER_NAME)
9
+
10
+ export const [SliderProvider, useSliderContext] =
11
+ createSliderContext<SliderContextValue>(SLIDER_NAME)
12
+
13
+ export const [SliderOrientationProvider, useSliderOrientationContext] = createSliderContext<{
14
+ startEdge: 'bottom' | 'left' | 'right'
15
+ endEdge: 'top' | 'right' | 'left'
16
+ sizeProp: 'width' | 'height'
17
+ size: number | SizeTokens
18
+ direction: number
19
+ }>(SLIDER_NAME, {
20
+ startEdge: 'left',
21
+ endEdge: 'right',
22
+ sizeProp: 'width',
23
+ size: 0,
24
+ direction: 1,
25
+ })
26
+
27
+ export const PAGE_KEYS = ['PageUp', 'PageDown']
28
+ export const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']
29
+ export const BACK_KEYS: Record<Direction, string[]> = {
30
+ ltr: ['ArrowDown', 'Home', 'ArrowLeft', 'PageDown'],
31
+ rtl: ['ArrowDown', 'Home', 'ArrowRight', 'PageDown'],
32
+ }
@@ -0,0 +1,102 @@
1
+ import { SizeTokens, getTokens, getVariableValue } from '@tamagui/core'
2
+
3
+ export function getNextSortedValues(prevValues: number[] = [], nextValue: number, atIndex: number) {
4
+ const nextValues = [...prevValues]
5
+ nextValues[atIndex] = nextValue
6
+ return nextValues.sort((a, b) => a - b)
7
+ }
8
+
9
+ export function convertValueToPercentage(value: number, min: number, max: number) {
10
+ const maxSteps = max - min
11
+ const percentPerStep = 100 / maxSteps
12
+ return percentPerStep * (value - min)
13
+ }
14
+
15
+ /**
16
+ * Returns a label for each thumb when there are two or more thumbs
17
+ */
18
+ export function getLabel(index: number, totalValues: number) {
19
+ if (totalValues > 2) {
20
+ return `Value ${index + 1} of ${totalValues}`
21
+ } else if (totalValues === 2) {
22
+ return ['Minimum', 'Maximum'][index]
23
+ } else {
24
+ return undefined
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Given a `values` array and a `nextValue`, determine which value in
30
+ * the array is closest to `nextValue` and return its index.
31
+ *
32
+ * @example
33
+ * // returns 1
34
+ * getClosestValueIndex([10, 30], 25);
35
+ */
36
+ export function getClosestValueIndex(values: number[], nextValue: number) {
37
+ if (values.length === 1) return 0
38
+ const distances = values.map((value) => Math.abs(value - nextValue))
39
+ const closestDistance = Math.min(...distances)
40
+ return distances.indexOf(closestDistance)
41
+ }
42
+
43
+ /**
44
+ * Offsets the thumb centre point while sliding to ensure it remains
45
+ * within the bounds of the slider when reaching the edges
46
+ */
47
+ export function getThumbInBoundsOffset(width: number, left: number, direction: number) {
48
+ const halfWidth = width / 2
49
+ const halfPercent = 50
50
+ const offset = linearScale([0, halfPercent], [0, halfWidth])
51
+ return (halfWidth - offset(left) * direction) * direction
52
+ }
53
+
54
+ /**
55
+ * Gets an array of steps between each value.
56
+ *
57
+ * @example
58
+ * // returns [1, 9]
59
+ * getStepsBetweenValues([10, 11, 20]);
60
+ */
61
+ function getStepsBetweenValues(values: number[]) {
62
+ return values.slice(0, -1).map((value, index) => values[index + 1] - value)
63
+ }
64
+
65
+ /**
66
+ * Verifies the minimum steps between all values is greater than or equal
67
+ * to the expected minimum steps.
68
+ *
69
+ * @example
70
+ * // returns false
71
+ * hasMinStepsBetweenValues([1,2,3], 2);
72
+ *
73
+ * @example
74
+ * // returns true
75
+ * hasMinStepsBetweenValues([1,2,3], 1);
76
+ */
77
+ export function hasMinStepsBetweenValues(values: number[], minStepsBetweenValues: number) {
78
+ if (minStepsBetweenValues > 0) {
79
+ const stepsBetweenValues = getStepsBetweenValues(values)
80
+ const actualMinStepsBetweenValues = Math.min(...stepsBetweenValues)
81
+ return actualMinStepsBetweenValues >= minStepsBetweenValues
82
+ }
83
+ return true
84
+ }
85
+
86
+ // https://github.com/tmcw-up-for-adoption/simple-linear-scale/blob/master/index.js
87
+ export function linearScale(input: readonly [number, number], output: readonly [number, number]) {
88
+ return (value: number) => {
89
+ if (input[0] === input[1] || output[0] === output[1]) return output[0]
90
+ const ratio = (output[1] - output[0]) / (input[1] - input[0])
91
+ return output[0] + ratio * (value - input[0])
92
+ }
93
+ }
94
+
95
+ export function getDecimalCount(value: number) {
96
+ return (String(value).split('.')[1] || '').length
97
+ }
98
+
99
+ export function roundValue(value: number, decimalCount: number) {
100
+ const rounder = Math.pow(10, decimalCount)
101
+ return Math.round(value * rounder) / rounder
102
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from './Slider'
2
+ export type {
3
+ SliderProps,
4
+ SliderHorizontalProps,
5
+ SliderVerticalProps,
6
+ SliderTrackProps,
7
+ } from './types'
package/src/types.ts ADDED
@@ -0,0 +1,78 @@
1
+ import type { GestureReponderEvent, SizeTokens } from '@tamagui/core'
2
+ import type { Scope } from '@tamagui/create-context'
3
+ import type { SizableStackProps } from '@tamagui/stacks'
4
+ import type { View } from 'react-native'
5
+
6
+ export type ScopedProps<P> = P & { __scopeSlider?: Scope }
7
+
8
+ export type Direction = 'ltr' | 'rtl'
9
+
10
+ export type SliderImplElement = HTMLElement | View
11
+
12
+ type SliderImplPrivateProps = {
13
+ onSlideStart(event: GestureReponderEvent, target: 'thumb' | 'track'): void
14
+ onSlideMove(event: GestureReponderEvent): void
15
+ onSlideEnd(event: GestureReponderEvent): void
16
+ onHomeKeyDown(event: React.KeyboardEvent): void
17
+ onEndKeyDown(event: React.KeyboardEvent): void
18
+ onStepKeyDown(event: React.KeyboardEvent): void
19
+ }
20
+
21
+ export interface SliderTrackProps extends SizableStackProps {}
22
+
23
+ export interface SliderImplProps extends SliderTrackProps, SliderImplPrivateProps {
24
+ dir?: Direction
25
+ orientation: 'horizontal' | 'vertical'
26
+ }
27
+
28
+ type SliderOrientationPrivateProps = {
29
+ min: number
30
+ max: number
31
+ onSlideStart?(value: number, target: 'thumb' | 'track'): void
32
+ onSlideMove?(value: number): void
33
+ onHomeKeyDown(event: React.KeyboardEvent): void
34
+ onEndKeyDown(event: React.KeyboardEvent): void
35
+ onStepKeyDown(step: { event: React.KeyboardEvent; direction: number }): void
36
+ }
37
+
38
+ interface SliderOrientationProps
39
+ extends Omit<SliderImplProps, keyof SliderImplPrivateProps | 'orientation'>,
40
+ SliderOrientationPrivateProps {}
41
+
42
+ export interface SliderHorizontalProps extends SliderOrientationProps {
43
+ dir?: Direction
44
+ }
45
+
46
+ export interface SliderVerticalProps extends SliderOrientationProps {
47
+ dir?: Direction
48
+ }
49
+
50
+ export interface SliderProps
51
+ extends Omit<
52
+ SliderHorizontalProps | SliderVerticalProps,
53
+ keyof SliderOrientationPrivateProps | 'defaultValue'
54
+ > {
55
+ size?: SizeTokens
56
+ name?: string
57
+ disabled?: boolean
58
+ orientation?: React.AriaAttributes['aria-orientation']
59
+ dir?: Direction
60
+ min?: number
61
+ max?: number
62
+ step?: number
63
+ minStepsBetweenThumbs?: number
64
+ value?: number[]
65
+ defaultValue?: number[]
66
+ onValueChange?(value: number[]): void
67
+ }
68
+
69
+ export type SliderContextValue = {
70
+ size?: SizeTokens | number | null
71
+ disabled?: boolean
72
+ min: number
73
+ max: number
74
+ values: number[]
75
+ valueIndexToChangeRef: React.MutableRefObject<number>
76
+ thumbs: Set<any>
77
+ orientation: SliderProps['orientation']
78
+ }