panelui-native 0.9.0 → 0.13.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 (106) hide show
  1. package/README.md +5 -3
  2. package/lib/module/components/bottom-sheet/index.js +38 -2
  3. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  4. package/lib/module/components/button/index.js +19 -1
  5. package/lib/module/components/button/index.js.map +1 -1
  6. package/lib/module/components/frame/index.js +128 -15
  7. package/lib/module/components/frame/index.js.map +1 -1
  8. package/lib/module/components/input/index.js +107 -15
  9. package/lib/module/components/input/index.js.map +1 -1
  10. package/lib/module/components/popover/index.js +22 -11
  11. package/lib/module/components/popover/index.js.map +1 -1
  12. package/lib/module/components/progress/index.js +52 -2
  13. package/lib/module/components/progress/index.js.map +1 -1
  14. package/lib/module/components/radio-group/index.js +34 -7
  15. package/lib/module/components/radio-group/index.js.map +1 -1
  16. package/lib/module/components/section-rail/index.js +355 -0
  17. package/lib/module/components/section-rail/index.js.map +1 -0
  18. package/lib/module/components/select/index.js +245 -56
  19. package/lib/module/components/select/index.js.map +1 -1
  20. package/lib/module/components/slider/index.js +355 -0
  21. package/lib/module/components/slider/index.js.map +1 -0
  22. package/lib/module/components/switch/index.js +10 -1
  23. package/lib/module/components/switch/index.js.map +1 -1
  24. package/lib/module/components/tabs/index.js +186 -16
  25. package/lib/module/components/tabs/index.js.map +1 -1
  26. package/lib/module/components/toggle-button/index.js +297 -0
  27. package/lib/module/components/toggle-button/index.js.map +1 -0
  28. package/lib/module/hooks/index.js +1 -0
  29. package/lib/module/hooks/index.js.map +1 -1
  30. package/lib/module/hooks/use-keyboard-avoidance.js +57 -8
  31. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  32. package/lib/module/hooks/use-scroll-sections.js +89 -0
  33. package/lib/module/hooks/use-scroll-sections.js.map +1 -0
  34. package/lib/module/index.js +4 -1
  35. package/lib/module/index.js.map +1 -1
  36. package/lib/module/native/index.js.map +1 -1
  37. package/lib/module/primitives/keyboard-avoider.js +2 -0
  38. package/lib/module/primitives/keyboard-avoider.js.map +1 -1
  39. package/lib/module/utils/haptics.js +57 -0
  40. package/lib/module/utils/haptics.js.map +1 -0
  41. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/button/index.d.ts +9 -4
  43. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  44. package/lib/typescript/src/components/frame/index.d.ts +47 -7
  45. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  46. package/lib/typescript/src/components/input/index.d.ts +113 -2
  47. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  48. package/lib/typescript/src/components/popover/index.d.ts +7 -1
  49. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/progress/index.d.ts +33 -0
  51. package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/radio-group/index.d.ts +7 -0
  53. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  54. package/lib/typescript/src/components/section-rail/index.d.ts +115 -0
  55. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -0
  56. package/lib/typescript/src/components/select/index.d.ts +46 -7
  57. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/slider/index.d.ts +203 -0
  59. package/lib/typescript/src/components/slider/index.d.ts.map +1 -0
  60. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  61. package/lib/typescript/src/components/tabs/index.d.ts +46 -4
  62. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  63. package/lib/typescript/src/components/toggle-button/index.d.ts +244 -0
  64. package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -0
  65. package/lib/typescript/src/hooks/index.d.ts +1 -0
  66. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  67. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +10 -1
  68. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  69. package/lib/typescript/src/hooks/use-scroll-sections.d.ts +31 -0
  70. package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -0
  71. package/lib/typescript/src/index.d.ts +6 -3
  72. package/lib/typescript/src/index.d.ts.map +1 -1
  73. package/lib/typescript/src/native/index.d.ts +16 -1
  74. package/lib/typescript/src/native/index.d.ts.map +1 -1
  75. package/lib/typescript/src/primitives/keyboard-avoider.d.ts +8 -1
  76. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
  77. package/lib/typescript/src/utils/haptics.d.ts +12 -0
  78. package/lib/typescript/src/utils/haptics.d.ts.map +1 -0
  79. package/package.json +5 -1
  80. package/src/components/bottom-sheet/index.tsx +38 -2
  81. package/src/components/button/index.tsx +26 -5
  82. package/src/components/chip/index.tsx +333 -0
  83. package/src/components/frame/index.tsx +175 -29
  84. package/src/components/input/index.tsx +122 -16
  85. package/src/components/popover/index.tsx +49 -24
  86. package/src/components/progress/index.tsx +81 -1
  87. package/src/components/radio-group/index.tsx +55 -8
  88. package/src/components/section-rail/index.tsx +459 -0
  89. package/src/components/select/index.tsx +265 -52
  90. package/src/components/slider/index.tsx +371 -0
  91. package/src/components/switch/index.tsx +8 -1
  92. package/src/components/tabs/index.tsx +208 -28
  93. package/src/components/toggle-button/index.tsx +396 -0
  94. package/src/hooks/index.ts +5 -0
  95. package/src/hooks/use-keyboard-avoidance.ts +73 -7
  96. package/src/hooks/use-scroll-sections.ts +127 -0
  97. package/src/index.ts +45 -6
  98. package/src/native/index.ts +13 -1
  99. package/src/primitives/keyboard-avoider.tsx +9 -1
  100. package/src/utils/haptics.ts +63 -0
  101. package/theme.css +6 -2
  102. package/lib/module/components/inline-select/index.js +0 -284
  103. package/lib/module/components/inline-select/index.js.map +0 -1
  104. package/lib/typescript/src/components/inline-select/index.d.ts +0 -48
  105. package/lib/typescript/src/components/inline-select/index.d.ts.map +0 -1
  106. package/src/components/inline-select/index.tsx +0 -332
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Slider — a value picker driven by a single thumb.
3
+ *
4
+ * The thumb is a pill exactly as tall as the track rather than a disc floating
5
+ * over it. That is a deliberate shape choice and it is also the robust one: a
6
+ * knob taller than its track has to escape the track's own bounds to be drawn
7
+ * whole, which puts the visual design at the mercy of a clipping rule. Here the
8
+ * two are the same height, so there is nothing to clip and nothing to escape.
9
+ *
10
+ * Inside the pill sits a smaller knob that shrinks while dragging — the press
11
+ * feedback lives on the part you are looking at, not on the whole control.
12
+ *
13
+ * Position and fill width are animated on the UI thread; dragging never
14
+ * round-trips through React, and the picked value bridges back to JS on change
15
+ * and on release.
16
+ *
17
+ * ```tsx
18
+ * <Slider label="Volume" showValue defaultValue={30} />
19
+ * ```
20
+ *
21
+ * Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).
22
+ */
23
+ import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
24
+ import {
25
+ View,
26
+ type AccessibilityActionEvent,
27
+ type LayoutChangeEvent,
28
+ } from 'react-native';
29
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
30
+ import Animated, {
31
+ runOnJS,
32
+ useAnimatedStyle,
33
+ useSharedValue,
34
+ withSpring,
35
+ } from 'react-native-reanimated';
36
+ import { tv, type VariantProps } from 'tailwind-variants';
37
+ import { getNativeUI } from '../../native';
38
+ import { Text } from '../../primitives/text';
39
+
40
+ /**
41
+ * Springs the thumb onto its resting step after a drag or a track tap. Drags
42
+ * themselves are followed 1:1 with no spring, so the knob stays under the
43
+ * finger; the spring only settles the final snap.
44
+ */
45
+ const SPRING = { damping: 18, stiffness: 220, mass: 0.6 } as const;
46
+ /** Settles the knob between its idle and dragging sizes. */
47
+ const KNOB_SPRING = { damping: 15, stiffness: 200, mass: 0.5 } as const;
48
+ /** How far the knob shrinks while the thumb is held. */
49
+ const KNOB_PRESSED_SCALE = 0.86;
50
+ /** Grab radius around the thumb — the pill is small, the target should not be. */
51
+ const HIT_SLOP = 16;
52
+
53
+ const sliderVariants = tv({
54
+ slots: {
55
+ root: 'w-full gap-2',
56
+ header: 'w-full flex-row items-center justify-between gap-3',
57
+ label: 'text-sm font-medium text-foreground',
58
+ value: 'text-sm text-muted-foreground',
59
+ // The track is the full height of the control, so the thumb has somewhere
60
+ // to sit rather than somewhere to stick out of.
61
+ track: 'w-full justify-center rounded-full bg-muted',
62
+ fill: 'absolute bottom-0 left-0 top-0 rounded-full',
63
+ thumb: 'absolute left-0 rounded-full p-0.5',
64
+ // `background`, not a per-colour on-token: the status foregrounds are the
65
+ // darker text hues meant for soft fills, so a green-700 knob on a green-500
66
+ // pill would barely show. The page background reads against every fill in
67
+ // both themes.
68
+ knob: 'flex-1 rounded-full bg-background shadow-sm',
69
+ },
70
+ variants: {
71
+ color: {
72
+ primary: { fill: 'bg-primary', thumb: 'bg-primary' },
73
+ success: { fill: 'bg-success', thumb: 'bg-success' },
74
+ warning: { fill: 'bg-warning', thumb: 'bg-warning' },
75
+ destructive: { fill: 'bg-destructive', thumb: 'bg-destructive' },
76
+ info: { fill: 'bg-info', thumb: 'bg-info' },
77
+ },
78
+ size: {
79
+ sm: { track: 'h-4', thumb: 'h-4' },
80
+ md: { track: 'h-5', thumb: 'h-5' },
81
+ lg: { track: 'h-6', thumb: 'h-6' },
82
+ },
83
+ disabled: {
84
+ true: { root: 'opacity-50' },
85
+ },
86
+ },
87
+ defaultVariants: {
88
+ color: 'primary',
89
+ size: 'md',
90
+ },
91
+ });
92
+
93
+ /** Thumb width per size. Wider than it is tall, so the pill reads as a grip. */
94
+ const THUMB_WIDTH: Record<'sm' | 'md' | 'lg', number> = { sm: 24, md: 28, lg: 32 };
95
+
96
+ /** Row height reserved for the platform's own slider. */
97
+ const NATIVE_HEIGHT = 32;
98
+
99
+ type SliderVariantProps = VariantProps<typeof sliderVariants>;
100
+
101
+ export interface SliderProps extends Omit<SliderVariantProps, 'disabled'> {
102
+ className?: string;
103
+ /** Controlled value. Leave unset and pass `defaultValue` to run uncontrolled. */
104
+ value?: number;
105
+ /** Starting value when uncontrolled. */
106
+ defaultValue?: number;
107
+ /** Lower bound. */
108
+ min?: number;
109
+ /** Upper bound. */
110
+ max?: number;
111
+ /** Snap granularity. The value is always a multiple of `step` from `min`. */
112
+ step?: number;
113
+ /** Fires on every change while dragging — cheap updates only. */
114
+ onValueChange?: (value: number) => void;
115
+ /** Fires once when the gesture ends — the place for expensive side effects. */
116
+ onValueCommit?: (value: number) => void;
117
+ disabled?: boolean;
118
+ /**
119
+ * Render the platform's own slider instead of this one. Requires the
120
+ * optional `@expo/ui` package; without it this prop does nothing.
121
+ *
122
+ * **Theme tokens do not apply** — the platform draws the control, so
123
+ * `color`, `size` and the slot classNames are ignored. `label` and
124
+ * `showValue` still render the caption row above it, since that is ours.
125
+ */
126
+ native?: boolean;
127
+ /** Caption above the track. Also becomes the accessibility label. */
128
+ label?: string;
129
+ /** Show the current value on the caption row, opposite the label. */
130
+ showValue?: boolean;
131
+ /** Format the shown value. Defaults to the rounded number. */
132
+ formatValue?: (value: number) => string;
133
+ /** Extra classes for the caption row. */
134
+ headerClassName?: string;
135
+ /** Extra classes for the unfilled track. */
136
+ trackClassName?: string;
137
+ /** Extra classes for the filled portion. */
138
+ fillClassName?: string;
139
+ /** Extra classes for the draggable thumb. */
140
+ thumbClassName?: string;
141
+ /** Extra classes for the knob inside the thumb. */
142
+ knobClassName?: string;
143
+ }
144
+
145
+ function clampJS(value: number, min: number, max: number) {
146
+ return Math.min(Math.max(value, min), max);
147
+ }
148
+
149
+ function snap(value: number, min: number, max: number, step: number) {
150
+ if (step <= 0) return clampJS(value, min, max);
151
+ const stepped = Math.round((value - min) / step) * step + min;
152
+ return clampJS(stepped, min, max);
153
+ }
154
+
155
+ export const Slider = forwardRef<View, SliderProps>(
156
+ (
157
+ {
158
+ className,
159
+ headerClassName,
160
+ trackClassName,
161
+ fillClassName,
162
+ thumbClassName,
163
+ knobClassName,
164
+ value: valueProp,
165
+ defaultValue = 0,
166
+ min = 0,
167
+ max = 100,
168
+ step = 1,
169
+ onValueChange,
170
+ onValueCommit,
171
+ disabled = false,
172
+ native,
173
+ label,
174
+ showValue = false,
175
+ formatValue,
176
+ color = 'primary',
177
+ size = 'md',
178
+ },
179
+ ref
180
+ ) => {
181
+ const isControlled = valueProp !== undefined;
182
+ const [internal, setInternal] = useState(defaultValue);
183
+ const value = clampJS(isControlled ? valueProp! : internal, min, max);
184
+
185
+ const slots = sliderVariants({ color, size, disabled });
186
+ const thumbWidth = THUMB_WIDTH[size ?? 'md'];
187
+ const nativeUI = native ? getNativeUI() : null;
188
+
189
+ // Measured on layout; the thumb travels across (trackWidth - thumbWidth) so
190
+ // it stays inside the track at both ends.
191
+ const trackWidth = useSharedValue(0);
192
+ // Fraction 0..1 that drives the fill and the thumb, animated on the UI thread.
193
+ const progress = useSharedValue(max > min ? (value - min) / (max - min) : 0);
194
+ const startProgress = useSharedValue(0);
195
+ const pressed = useSharedValue(0);
196
+
197
+ // The change handler runs on JS; keep the latest props reachable from the
198
+ // worklet callback without re-creating the gesture on every render.
199
+ const changeRef = useRef(onValueChange);
200
+ changeRef.current = onValueChange;
201
+ const commitRef = useRef(onValueCommit);
202
+ commitRef.current = onValueCommit;
203
+
204
+ const emitChange = useCallback(
205
+ (next: number) => {
206
+ if (!isControlled) setInternal(next);
207
+ changeRef.current?.(next);
208
+ },
209
+ [isControlled]
210
+ );
211
+ const emitCommit = useCallback((next: number) => {
212
+ commitRef.current?.(next);
213
+ }, []);
214
+
215
+ // Keep the animation in step with a controlled value that changes elsewhere.
216
+ useEffect(() => {
217
+ const next = max > min ? (value - min) / (max - min) : 0;
218
+ progress.value = withSpring(next, SPRING);
219
+ }, [value, min, max, progress]);
220
+
221
+ const onLayout = (event: LayoutChangeEvent) => {
222
+ trackWidth.value = event.nativeEvent.layout.width;
223
+ };
224
+
225
+ const commitFromProgress = useCallback(
226
+ (p: number, commit: boolean) => {
227
+ const raw = min + p * (max - min);
228
+ const snapped = snap(raw, min, max, step);
229
+ emitChange(snapped);
230
+ if (commit) emitCommit(snapped);
231
+ },
232
+ [min, max, step, emitChange, emitCommit]
233
+ );
234
+
235
+ const pan = Gesture.Pan()
236
+ .enabled(!disabled)
237
+ .hitSlop(HIT_SLOP)
238
+ .onBegin(() => {
239
+ startProgress.value = progress.value;
240
+ pressed.value = withSpring(1, KNOB_SPRING);
241
+ })
242
+ .onUpdate((event) => {
243
+ const travel = Math.max(trackWidth.value - thumbWidth, 1);
244
+ const delta = event.translationX / travel;
245
+ const next = Math.min(Math.max(startProgress.value + delta, 0), 1);
246
+ progress.value = next;
247
+ runOnJS(commitFromProgress)(next, false);
248
+ })
249
+ .onFinalize(() => {
250
+ pressed.value = withSpring(0, KNOB_SPRING);
251
+ runOnJS(commitFromProgress)(progress.value, true);
252
+ });
253
+
254
+ const tap = Gesture.Tap()
255
+ .enabled(!disabled)
256
+ .maxDuration(250)
257
+ .onEnd((event) => {
258
+ const travel = Math.max(trackWidth.value - thumbWidth, 1);
259
+ const next = Math.min(Math.max((event.x - thumbWidth / 2) / travel, 0), 1);
260
+ progress.value = withSpring(next, SPRING);
261
+ runOnJS(commitFromProgress)(next, true);
262
+ });
263
+
264
+ const gesture = Gesture.Race(pan, tap);
265
+
266
+ // The fill runs under the thumb rather than up to it, so the two never show
267
+ // a seam between them as the thumb moves.
268
+ const fillStyle = useAnimatedStyle(() => {
269
+ const travel = Math.max(trackWidth.value - thumbWidth, 0);
270
+ return { width: progress.value * travel + thumbWidth };
271
+ });
272
+
273
+ const thumbStyle = useAnimatedStyle(() => {
274
+ const travel = Math.max(trackWidth.value - thumbWidth, 0);
275
+ return { transform: [{ translateX: progress.value * travel }] };
276
+ });
277
+
278
+ const knobStyle = useAnimatedStyle(() => ({
279
+ transform: [{ scale: 1 - pressed.value * (1 - KNOB_PRESSED_SCALE) }],
280
+ }));
281
+
282
+ // VoiceOver / TalkBack increment and decrement move by a single step.
283
+ const nudge = (dir: 1 | -1) => {
284
+ const next = snap(value + dir * (step || 1), min, max, step);
285
+ if (next === value) return;
286
+ progress.value = withSpring(max > min ? (next - min) / (max - min) : 0, SPRING);
287
+ emitChange(next);
288
+ emitCommit(next);
289
+ };
290
+
291
+ const onAccessibilityAction = (event: AccessibilityActionEvent) => {
292
+ if (event.nativeEvent.actionName === 'increment') nudge(1);
293
+ else if (event.nativeEvent.actionName === 'decrement') nudge(-1);
294
+ };
295
+
296
+ const shownValue = formatValue ? formatValue(value) : String(Math.round(value));
297
+
298
+ const header =
299
+ label || showValue ? (
300
+ <View className={slots.header({ className: headerClassName })}>
301
+ {label ? <Text className={slots.label()}>{label}</Text> : <View />}
302
+ {showValue ? <Text className={slots.value()}>{shownValue}</Text> : null}
303
+ </View>
304
+ ) : null;
305
+
306
+ if (nativeUI) {
307
+ const { Host, Slider: NativeSlider } = nativeUI;
308
+ return (
309
+ <View ref={ref} className={slots.root({ className })}>
310
+ {header}
311
+ {/* Told its height rather than left to measure the platform's own
312
+ content, which arrives a frame late — a host that has to work its
313
+ size out renders at nothing and then jumps. */}
314
+ <Host style={{ height: NATIVE_HEIGHT }}>
315
+ <NativeSlider
316
+ value={value}
317
+ onValueChange={(next: number) => {
318
+ emitChange(snap(next, min, max, step));
319
+ }}
320
+ min={min}
321
+ max={max}
322
+ step={step}
323
+ disabled={disabled}
324
+ />
325
+ </Host>
326
+ </View>
327
+ );
328
+ }
329
+
330
+ return (
331
+ <View ref={ref} className={slots.root({ className })} collapsable={false}>
332
+ {header}
333
+
334
+ <GestureDetector gesture={gesture}>
335
+ <View
336
+ className={slots.track({ className: trackClassName })}
337
+ onLayout={onLayout}
338
+ >
339
+ <Animated.View
340
+ style={fillStyle}
341
+ className={slots.fill({ className: fillClassName })}
342
+ />
343
+ <Animated.View
344
+ style={[thumbStyle, { width: thumbWidth }]}
345
+ className={slots.thumb({ className: thumbClassName })}
346
+ accessible
347
+ accessibilityRole="adjustable"
348
+ accessibilityLabel={label}
349
+ accessibilityState={{ disabled }}
350
+ accessibilityValue={{
351
+ min,
352
+ max,
353
+ now: Math.round(value),
354
+ text: shownValue,
355
+ }}
356
+ accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
357
+ onAccessibilityAction={onAccessibilityAction}
358
+ >
359
+ <Animated.View
360
+ style={knobStyle}
361
+ className={slots.knob({ className: knobClassName })}
362
+ />
363
+ </Animated.View>
364
+ </View>
365
+ </GestureDetector>
366
+ </View>
367
+ );
368
+ }
369
+ );
370
+
371
+ Slider.displayName = 'Slider';
@@ -33,6 +33,13 @@ const switchVariants = tv({
33
33
 
34
34
  const TRAVEL: Record<'sm' | 'md', number> = { sm: 16, md: 20 };
35
35
 
36
+ /**
37
+ * Row height given to the native host. It is told its size rather than asked
38
+ * to measure the platform's own content, which arrives a frame late and leaves
39
+ * the control collapsed until something forces a second pass.
40
+ */
41
+ const NATIVE_HEIGHT = 32;
42
+
36
43
  export interface SwitchProps extends VariantProps<typeof switchVariants> {
37
44
  className?: string;
38
45
  value: boolean;
@@ -77,7 +84,7 @@ export const Switch = forwardRef<View, SwitchProps>(
77
84
  if (nativeUI) {
78
85
  const { Host, Switch: NativeSwitch } = nativeUI;
79
86
  return (
80
- <Host matchContents>
87
+ <Host style={{ height: NATIVE_HEIGHT }}>
81
88
  <NativeSwitch
82
89
  value={value}
83
90
  onValueChange={(next: boolean) => onValueChange?.(next)}