panelui-native 0.9.0 → 0.12.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 (105) 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/frame/index.tsx +175 -29
  83. package/src/components/input/index.tsx +122 -16
  84. package/src/components/popover/index.tsx +49 -24
  85. package/src/components/progress/index.tsx +81 -1
  86. package/src/components/radio-group/index.tsx +55 -8
  87. package/src/components/section-rail/index.tsx +459 -0
  88. package/src/components/select/index.tsx +265 -52
  89. package/src/components/slider/index.tsx +371 -0
  90. package/src/components/switch/index.tsx +8 -1
  91. package/src/components/tabs/index.tsx +208 -28
  92. package/src/components/toggle-button/index.tsx +374 -0
  93. package/src/hooks/index.ts +5 -0
  94. package/src/hooks/use-keyboard-avoidance.ts +73 -7
  95. package/src/hooks/use-scroll-sections.ts +127 -0
  96. package/src/index.ts +37 -6
  97. package/src/native/index.ts +13 -1
  98. package/src/primitives/keyboard-avoider.tsx +9 -1
  99. package/src/utils/haptics.ts +63 -0
  100. package/theme.css +6 -2
  101. package/lib/module/components/inline-select/index.js +0 -284
  102. package/lib/module/components/inline-select/index.js.map +0 -1
  103. package/lib/typescript/src/components/inline-select/index.d.ts +0 -48
  104. package/lib/typescript/src/components/inline-select/index.d.ts.map +0 -1
  105. package/src/components/inline-select/index.tsx +0 -332
@@ -0,0 +1,374 @@
1
+ /**
2
+ * ToggleButton — a button that stays down.
3
+ *
4
+ * The difference from Button is the whole point: a button does something and
5
+ * springs back, a toggle button *is* something afterwards. So the selected
6
+ * state is the loud one — a filled accent surface — and the resting state is
7
+ * quiet, either a soft fill or nothing at all.
8
+ *
9
+ * ```tsx
10
+ * <ToggleButton defaultSelected>Like</ToggleButton>
11
+ *
12
+ * <ToggleButton iconOnly accessibilityLabel="Bold">
13
+ * <BoldIcon size={16} />
14
+ * </ToggleButton>
15
+ * ```
16
+ *
17
+ * `ToggleButtonGroup` takes over the state for a set of them — a segmented
18
+ * toolbar in `multiple` mode, an either-or choice in `single`. Inside a group
19
+ * each button needs an `id`, which is the value the group reports.
20
+ *
21
+ * ```tsx
22
+ * <ToggleButtonGroup selectionMode="multiple" value={marks} onValueChange={setMarks}>
23
+ * <ToggleButton id="bold" iconOnly accessibilityLabel="Bold">…</ToggleButton>
24
+ * <ToggleButton id="italic" iconOnly accessibilityLabel="Italic">…</ToggleButton>
25
+ * </ToggleButtonGroup>
26
+ * ```
27
+ */
28
+ import {
29
+ createContext,
30
+ forwardRef,
31
+ useCallback,
32
+ useContext,
33
+ useMemo,
34
+ useState,
35
+ type ReactNode,
36
+ } from 'react';
37
+ import { View, type ViewProps } from 'react-native';
38
+ import { tv, type VariantProps } from 'tailwind-variants';
39
+ import { useCSSVariable } from 'uniwind';
40
+ import { IconColorProvider } from '../../icons';
41
+ import {
42
+ AnimatedPressable,
43
+ type AnimatedPressableProps,
44
+ } from '../../primitives/animated-pressable';
45
+ import { Text } from '../../primitives/text';
46
+ import { cn } from '../../utils/cn';
47
+
48
+ const toggleVariants = tv({
49
+ slots: {
50
+ root: 'flex-row items-center justify-center gap-2 rounded-lg border border-transparent',
51
+ label: 'font-medium',
52
+ },
53
+ variants: {
54
+ variant: {
55
+ /** A soft fill at rest, so the control is visible before you touch it. */
56
+ default: { root: 'bg-secondary' },
57
+ /** Nothing at rest — for a dense toolbar where six fills would be noise. */
58
+ ghost: { root: 'bg-transparent' },
59
+ },
60
+ size: {
61
+ sm: { root: 'h-9 gap-1.5 px-2.5', label: 'text-sm' },
62
+ md: { root: 'h-11 px-4', label: 'text-base' },
63
+ lg: { root: 'h-12 px-6', label: 'text-lg' },
64
+ },
65
+ selected: {
66
+ true: { root: 'border-primary/24 bg-accent', label: 'text-accent-foreground' },
67
+ false: { label: 'text-muted-foreground' },
68
+ },
69
+ iconOnly: {
70
+ true: { root: 'px-0' },
71
+ },
72
+ disabled: {
73
+ true: { root: 'opacity-[0.64]' },
74
+ },
75
+ },
76
+ compoundVariants: [
77
+ // Square, and sized from the height so the icon sits in the middle.
78
+ { iconOnly: true, size: 'sm', class: { root: 'w-9' } },
79
+ { iconOnly: true, size: 'md', class: { root: 'w-11' } },
80
+ { iconOnly: true, size: 'lg', class: { root: 'w-12' } },
81
+ ],
82
+ defaultVariants: {
83
+ variant: 'default',
84
+ size: 'md',
85
+ },
86
+ });
87
+
88
+ type ToggleVariantProps = VariantProps<typeof toggleVariants>;
89
+ export type ToggleButtonSize = NonNullable<ToggleVariantProps['size']>;
90
+ export type ToggleButtonVariant = NonNullable<ToggleVariantProps['variant']>;
91
+ export type ToggleSelectionMode = 'single' | 'multiple';
92
+
93
+ /* -------------------------------------------------------------------------- */
94
+ /* Group */
95
+ /* -------------------------------------------------------------------------- */
96
+
97
+ interface ToggleGroupContextValue {
98
+ isSelected: (id: string) => boolean;
99
+ toggle: (id: string) => void;
100
+ disabled: boolean;
101
+ variant?: ToggleButtonVariant;
102
+ size?: ToggleButtonSize;
103
+ }
104
+
105
+ const ToggleGroupContext = createContext<ToggleGroupContextValue | null>(null);
106
+
107
+ export interface ToggleButtonGroupProps extends ViewProps {
108
+ className?: string;
109
+ /**
110
+ * `multiple` is a set of independent marks — bold *and* italic. `single` is
111
+ * an either-or choice where picking one clears the last.
112
+ */
113
+ selectionMode?: ToggleSelectionMode;
114
+ /** Selected ids. Controlled — pair with `onValueChange`. */
115
+ value?: string[];
116
+ /** Starting selection when uncontrolled. */
117
+ defaultValue?: string[];
118
+ onValueChange?: (value: string[]) => void;
119
+ /** Disables every button in the group. */
120
+ disabled?: boolean;
121
+ /** Applied to every button that does not set its own. */
122
+ variant?: ToggleButtonVariant;
123
+ size?: ToggleButtonSize;
124
+ children: ReactNode;
125
+ }
126
+
127
+ const ToggleButtonGroup = forwardRef<View, ToggleButtonGroupProps>(
128
+ (
129
+ {
130
+ className,
131
+ selectionMode = 'multiple',
132
+ value,
133
+ defaultValue = [],
134
+ onValueChange,
135
+ disabled = false,
136
+ variant,
137
+ size,
138
+ children,
139
+ ...props
140
+ },
141
+ ref
142
+ ) => {
143
+ const isControlled = value !== undefined;
144
+ const [internal, setInternal] = useState<string[]>(defaultValue);
145
+ const selection = isControlled ? value : internal;
146
+
147
+ const toggle = useCallback(
148
+ (id: string) => {
149
+ const has = selection.includes(id);
150
+ const next =
151
+ selectionMode === 'single'
152
+ ? // Pressing the selected one again clears it. A toolbar filter you
153
+ // cannot turn off is a trap.
154
+ has
155
+ ? []
156
+ : [id]
157
+ : has
158
+ ? selection.filter((item) => item !== id)
159
+ : [...selection, id];
160
+
161
+ if (!isControlled) setInternal(next);
162
+ onValueChange?.(next);
163
+ },
164
+ [selection, selectionMode, isControlled, onValueChange]
165
+ );
166
+
167
+ const context = useMemo<ToggleGroupContextValue>(
168
+ () => ({
169
+ isSelected: (id) => selection.includes(id),
170
+ toggle,
171
+ disabled,
172
+ variant,
173
+ size,
174
+ }),
175
+ [selection, toggle, disabled, variant, size]
176
+ );
177
+
178
+ return (
179
+ <ToggleGroupContext.Provider value={context}>
180
+ <View
181
+ ref={ref}
182
+ // `radiogroup` for an either-or choice, `toolbar` for a set of
183
+ // independent marks — the two are read out differently, and which
184
+ // one applies is exactly what selectionMode says.
185
+ accessibilityRole={selectionMode === 'single' ? 'radiogroup' : 'toolbar'}
186
+ className={cn('flex-row items-center gap-1.5', className)}
187
+ {...props}
188
+ >
189
+ {children}
190
+ </View>
191
+ </ToggleGroupContext.Provider>
192
+ );
193
+ }
194
+ );
195
+ ToggleButtonGroup.displayName = 'ToggleButtonGroup';
196
+
197
+ /* -------------------------------------------------------------------------- */
198
+ /* Button */
199
+ /* -------------------------------------------------------------------------- */
200
+
201
+ interface ToggleStateValue {
202
+ selected: boolean;
203
+ disabled: boolean;
204
+ }
205
+
206
+ const ToggleStateContext = createContext<ToggleStateValue | null>(null);
207
+
208
+ /**
209
+ * The toggle's own state, for anything rendered inside it — an icon that
210
+ * changes shape when selected, a count that only shows when it is on.
211
+ */
212
+ export function useToggleButton(): ToggleStateValue {
213
+ const state = useContext(ToggleStateContext);
214
+ if (!state) {
215
+ throw new Error('useToggleButton must be used within a <ToggleButton>');
216
+ }
217
+ return state;
218
+ }
219
+
220
+ export interface ToggleButtonProps
221
+ extends Omit<AnimatedPressableProps, 'children' | 'disabled'>,
222
+ Omit<ToggleVariantProps, 'selected' | 'disabled' | 'iconOnly'> {
223
+ children?: ReactNode;
224
+ className?: string;
225
+ /** Extra classes for the label when children is a string. */
226
+ labelClassName?: string;
227
+ /** Identifies this button within a `ToggleButtonGroup`. Required there. */
228
+ id?: string;
229
+ /** Controlled selection. Ignored inside a group, which owns the state. */
230
+ selected?: boolean;
231
+ /** Starting state when uncontrolled and outside a group. */
232
+ defaultSelected?: boolean;
233
+ onSelectedChange?: (selected: boolean) => void;
234
+ disabled?: boolean;
235
+ /** Square, with no horizontal padding — for a single icon. */
236
+ iconOnly?: boolean;
237
+ /** Extra classes applied only while selected. */
238
+ selectedClassName?: string;
239
+ /** Extra classes applied only while unselected. */
240
+ unselectedClassName?: string;
241
+ }
242
+
243
+ const ToggleButtonRoot = forwardRef<View, ToggleButtonProps>(
244
+ (
245
+ {
246
+ children,
247
+ className,
248
+ labelClassName,
249
+ id,
250
+ selected: selectedProp,
251
+ defaultSelected = false,
252
+ onSelectedChange,
253
+ disabled,
254
+ variant,
255
+ size,
256
+ iconOnly = false,
257
+ selectedClassName,
258
+ unselectedClassName,
259
+ onPress,
260
+ ...props
261
+ },
262
+ ref
263
+ ) => {
264
+ const group = useContext(ToggleGroupContext);
265
+ const [internal, setInternal] = useState(defaultSelected);
266
+
267
+ /*
268
+ * Three sources of truth, in order: the group owns it if there is one,
269
+ * then a `selected` prop, then local state. The group comes first because
270
+ * a button inside one that also took a `selected` prop would fight it, and
271
+ * the group is the thing that knows about the other buttons.
272
+ */
273
+ const selected = group && id ? group.isSelected(id) : (selectedProp ?? internal);
274
+ const isDisabled = disabled || (group?.disabled ?? false);
275
+
276
+ const slots = toggleVariants({
277
+ variant: variant ?? group?.variant,
278
+ size: size ?? group?.size,
279
+ selected,
280
+ iconOnly,
281
+ disabled: isDisabled,
282
+ });
283
+
284
+ // Icons inside inherit this, so they follow the surface they sit on
285
+ // instead of every caller hardcoding a hex that is wrong in one theme.
286
+ const onColor = useCSSVariable('--color-accent-foreground');
287
+ const offColor = useCSSVariable('--color-muted-foreground');
288
+ const tint = selected ? onColor : offColor;
289
+
290
+ const handlePress = useCallback<NonNullable<AnimatedPressableProps['onPress']>>(
291
+ (event) => {
292
+ onPress?.(event);
293
+ if (group && id) {
294
+ group.toggle(id);
295
+ return;
296
+ }
297
+ const next = !selected;
298
+ if (selectedProp === undefined) setInternal(next);
299
+ onSelectedChange?.(next);
300
+ },
301
+ [onPress, group, id, selected, selectedProp, onSelectedChange]
302
+ );
303
+
304
+ const state = useMemo(
305
+ () => ({ selected, disabled: isDisabled }),
306
+ [selected, isDisabled]
307
+ );
308
+
309
+ return (
310
+ <ToggleStateContext.Provider value={state}>
311
+ <IconColorProvider color={typeof tint === 'string' ? tint : undefined}>
312
+ <AnimatedPressable
313
+ ref={ref}
314
+ accessibilityRole="button"
315
+ // `checked` is what a screen reader reads out as "on"/"off"; the
316
+ // selected state is the entire point of this control, so it is
317
+ // not left to be inferred from the colour.
318
+ accessibilityState={{ checked: selected, disabled: isDisabled, selected }}
319
+ aria-pressed={selected}
320
+ disabled={isDisabled}
321
+ onPress={handlePress}
322
+ className={cn(
323
+ slots.root({ className }),
324
+ selected ? selectedClassName : unselectedClassName
325
+ )}
326
+ {...props}
327
+ >
328
+ {typeof children === 'string' ? (
329
+ <Text className={slots.label({ className: labelClassName })}>
330
+ {children}
331
+ </Text>
332
+ ) : (
333
+ children
334
+ )}
335
+ </AnimatedPressable>
336
+ </IconColorProvider>
337
+ </ToggleStateContext.Provider>
338
+ );
339
+ }
340
+ );
341
+ ToggleButtonRoot.displayName = 'ToggleButton';
342
+
343
+ export interface ToggleButtonLabelProps {
344
+ className?: string;
345
+ children: ReactNode;
346
+ }
347
+
348
+ /**
349
+ * The label, when the button holds more than a string — an icon beside text.
350
+ * It reads the selected state itself, so composing the two does not mean
351
+ * threading the colour through by hand.
352
+ */
353
+ function ToggleButtonLabel({ className, children }: ToggleButtonLabelProps) {
354
+ const { selected } = useToggleButton();
355
+
356
+ return (
357
+ <Text
358
+ className={cn(
359
+ 'font-medium',
360
+ selected ? 'text-accent-foreground' : 'text-muted-foreground',
361
+ className
362
+ )}
363
+ >
364
+ {children}
365
+ </Text>
366
+ );
367
+ }
368
+ ToggleButtonLabel.displayName = 'ToggleButton.Label';
369
+
370
+ export const ToggleButton = Object.assign(ToggleButtonRoot, {
371
+ Label: ToggleButtonLabel,
372
+ });
373
+
374
+ export { ToggleButtonGroup };
@@ -20,5 +20,10 @@ export {
20
20
  type UseKeyboardAvoidanceOptions,
21
21
  type UseKeyboardAvoidanceResult,
22
22
  } from './use-keyboard-avoidance';
23
+ export {
24
+ useScrollSections,
25
+ type UseScrollSectionsOptions,
26
+ type UseScrollSectionsResult,
27
+ } from './use-scroll-sections';
23
28
  export { useDebouncedValue } from './use-debounced-value';
24
29
  export { usePrevious } from './use-previous';
@@ -31,10 +31,12 @@
31
31
  * </Animated.View>
32
32
  * ```
33
33
  */
34
- import { useCallback } from 'react';
34
+ import { useCallback, useEffect } from 'react';
35
35
  import { useWindowDimensions, type LayoutChangeEvent, type View } from 'react-native';
36
36
  import {
37
+ measure,
37
38
  useAnimatedKeyboard,
39
+ useAnimatedReaction,
38
40
  useAnimatedRef,
39
41
  useAnimatedStyle,
40
42
  useSharedValue,
@@ -45,6 +47,15 @@ import {
45
47
  export interface UseKeyboardAvoidanceOptions {
46
48
  /** Set false to leave the element where it is. */
47
49
  enabled?: boolean;
50
+ /**
51
+ * Whether this element is the one that should get out of the keyboard's way.
52
+ *
53
+ * Defaults to true, which suits a composer or a toolbar: it rides the
54
+ * keyboard whatever is focused. A *field* wants the opposite — pass its own
55
+ * focus state, or every field on the screen lifts whenever any one of them
56
+ * is tapped, and they all arrive at the same place on top of each other.
57
+ */
58
+ active?: boolean;
48
59
  /** Gap to keep between the element's bottom edge and the keyboard. */
49
60
  offset?: number;
50
61
  }
@@ -98,6 +109,7 @@ export function hasKeyboardController(): boolean {
98
109
 
99
110
  export function useKeyboardAvoidance({
100
111
  enabled = true,
112
+ active = true,
101
113
  offset = 16,
102
114
  }: UseKeyboardAvoidanceOptions = {}): UseKeyboardAvoidanceResult {
103
115
  const ref = useAnimatedRef<View>();
@@ -107,26 +119,80 @@ export function useKeyboardAvoidance({
107
119
  /** Window-space bottom edge of the element with no translation applied. */
108
120
  const restingBottom = useSharedValue(0);
109
121
 
110
- const measure = useCallback(() => {
122
+ // `active` is a plain prop and the reaction below is a worklet, so it is
123
+ // mirrored rather than closed over — written in an effect, because touching
124
+ // a shared value during render is a Reanimated strict-mode violation.
125
+ const isActive = useSharedValue(active && enabled);
126
+ useEffect(() => {
127
+ isActive.value = active && enabled;
128
+ }, [active, enabled, isActive]);
129
+
130
+ /*
131
+ * The resting position is taken the moment this element becomes the one that
132
+ * should move, on the UI thread, rather than once at layout time.
133
+ *
134
+ * Measuring at layout is wrong in two ways that both show up as "avoidance
135
+ * does nothing". A field inside an overlay is laid out before the overlay
136
+ * knows where it goes — it is parked off-screen at that point, so the stored
137
+ * position is a large negative number that is nonetheless non-zero, and the
138
+ * arithmetic below happily concludes there is no overlap. And a field that
139
+ * has scrolled since it was laid out is measured where it used to be.
140
+ *
141
+ * Keying off "active and the keyboard is up" rather than off the keyboard
142
+ * alone matters for the second field you tap: moving straight from one field
143
+ * to another never closes the keyboard, so there is no opening to react to,
144
+ * and a hook watching only the keyboard would never measure it.
145
+ *
146
+ * At the instant it becomes active the element has no translation applied,
147
+ * so what is measured then is the honest resting position.
148
+ */
149
+ useAnimatedReaction(
150
+ () => isActive.value && Math.abs(rawHeight.value) > 0,
151
+ (shouldLift, wasLifting) => {
152
+ if (shouldLift === wasLifting) return;
153
+
154
+ if (!shouldLift) {
155
+ // Forget it, so the next time round measures wherever the element has
156
+ // got to in the meantime.
157
+ restingBottom.value = 0;
158
+ return;
159
+ }
160
+
161
+ const frame = measure(ref);
162
+ if (frame && frame.height > 0 && frame.pageY >= 0) {
163
+ restingBottom.value = frame.pageY + frame.height;
164
+ }
165
+ }
166
+ );
167
+
168
+ const seed = useCallback(() => {
169
+ // Skipped only when a position is already known and the keyboard is up —
170
+ // the element is lifted then, and this would store the lifted position as
171
+ // the resting one. With no position yet there is no translation to undo,
172
+ // so measuring is safe whatever the keyboard is doing.
173
+ if (restingBottom.value !== 0 && Math.abs(rawHeight.value) > 0) return;
174
+
111
175
  ref.current?.measureInWindow((_x, y, _width, height) => {
112
- if (height > 0) restingBottom.value = y + height;
176
+ if (height > 0 && y >= 0) restingBottom.value = y + height;
113
177
  });
114
- }, [ref, restingBottom]);
178
+ }, [ref, rawHeight, restingBottom]);
115
179
 
116
180
  const onLayout = useCallback(
117
181
  (_event: LayoutChangeEvent) => {
182
+ // Covers the case the reaction above cannot: a keyboard that is already
183
+ // up when this element mounts, where there is no transition to react to.
118
184
  // measureInWindow is only meaningful once the view is attached and
119
185
  // positioned, which is a frame later than onLayout on both platforms.
120
- requestAnimationFrame(measure);
186
+ requestAnimationFrame(seed);
121
187
  },
122
- [measure]
188
+ [seed]
123
189
  );
124
190
 
125
191
  const animatedStyle = useAnimatedStyle(() => {
126
192
  // Both sources are normalised to a positive height here.
127
193
  const keyboardHeight = Math.abs(rawHeight.value);
128
194
 
129
- if (!enabled || keyboardHeight === 0 || restingBottom.value === 0) {
195
+ if (!isActive.value || keyboardHeight === 0 || restingBottom.value === 0) {
130
196
  return { transform: [{ translateY: 0 }] };
131
197
  }
132
198
 
@@ -0,0 +1,127 @@
1
+ /**
2
+ * useScrollSections — tracks which section of a scroll view you are reading.
3
+ *
4
+ * The active section is the last one whose top has passed a reading line a
5
+ * little way down the viewport, so the heading you have just scrolled past
6
+ * still counts as the one you are in.
7
+ *
8
+ * That rule alone has a hole at the end, and it is the hole every hand-rolled
9
+ * scrollspy has: the final section's top may never reach the reading line,
10
+ * because the content runs out first. Its bar then only lights up if you
11
+ * over-scroll past the bottom. So being at the bottom of the scroll view is
12
+ * treated as being in the last section outright — there is no more scrolling
13
+ * left with which to get there.
14
+ *
15
+ * ```tsx
16
+ * const sections = useScrollSections({ ids: SECTIONS.map((s) => s.id) });
17
+ *
18
+ * <ScrollView ref={sections.ref} {...sections.scrollProps}>
19
+ * {SECTIONS.map((section) => (
20
+ * <View key={section.id} onLayout={sections.measure(section.id)}>…</View>
21
+ * ))}
22
+ * </ScrollView>
23
+ *
24
+ * <SectionRail value={sections.active} onValueChange={sections.scrollTo}>…</SectionRail>
25
+ * ```
26
+ */
27
+ import { useCallback, useMemo, useRef, useState } from 'react';
28
+ import type {
29
+ LayoutChangeEvent,
30
+ NativeScrollEvent,
31
+ NativeSyntheticEvent,
32
+ ScrollView,
33
+ } from 'react-native';
34
+
35
+ export interface UseScrollSectionsOptions {
36
+ /** Section ids, in the order they appear down the page. */
37
+ ids: string[];
38
+ /**
39
+ * How far down the viewport the reading line sits, in pixels. Larger values
40
+ * switch to the next section later.
41
+ */
42
+ offset?: number;
43
+ /** How close to the bottom counts as "at the bottom", in pixels. */
44
+ endThreshold?: number;
45
+ /** Extra gap left above a section when scrolling to it. */
46
+ scrollPadding?: number;
47
+ }
48
+
49
+ export interface UseScrollSectionsResult {
50
+ /** Attach to the ScrollView, so `scrollTo` has something to drive. */
51
+ ref: React.RefObject<ScrollView | null>;
52
+ /** The section being read. */
53
+ active: string | undefined;
54
+ /** Spread onto the ScrollView. */
55
+ scrollProps: {
56
+ onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
57
+ scrollEventThrottle: number;
58
+ };
59
+ /** `onLayout` for a section's wrapper: `onLayout={measure(id)}`. */
60
+ measure: (id: string) => (event: LayoutChangeEvent) => void;
61
+ /** Scroll a section to the top. Pass straight to a rail's `onValueChange`. */
62
+ scrollTo: (id: string) => void;
63
+ }
64
+
65
+ export function useScrollSections({
66
+ ids,
67
+ offset = 120,
68
+ endThreshold = 24,
69
+ scrollPadding = 0,
70
+ }: UseScrollSectionsOptions): UseScrollSectionsResult {
71
+ const ref = useRef<ScrollView | null>(null);
72
+ const offsets = useRef<Record<string, number>>({});
73
+ const [active, setActive] = useState<string | undefined>(ids[0]);
74
+
75
+ // Read inside the scroll handler, which must not be re-created on every
76
+ // render — a new handler each frame would defeat the throttle.
77
+ const idsRef = useRef(ids);
78
+ idsRef.current = ids;
79
+
80
+ const measure = useCallback(
81
+ (id: string) => (event: LayoutChangeEvent) => {
82
+ offsets.current[id] = event.nativeEvent.layout.y;
83
+ },
84
+ []
85
+ );
86
+
87
+ const onScroll = useCallback(
88
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
89
+ const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent;
90
+ const list = idsRef.current;
91
+ if (!list.length) return;
92
+
93
+ const atEnd =
94
+ contentOffset.y + layoutMeasurement.height >= contentSize.height - endThreshold;
95
+ if (atEnd) {
96
+ setActive(list[list.length - 1]);
97
+ return;
98
+ }
99
+
100
+ const line = contentOffset.y + offset;
101
+ let current = list[0];
102
+ for (const id of list) {
103
+ const top = offsets.current[id];
104
+ if (top !== undefined && top <= line) current = id;
105
+ }
106
+ setActive(current);
107
+ },
108
+ [offset, endThreshold]
109
+ );
110
+
111
+ const scrollTo = useCallback(
112
+ (id: string) => {
113
+ const top = offsets.current[id];
114
+ if (top === undefined) return;
115
+ setActive(id);
116
+ ref.current?.scrollTo({ y: Math.max(top - scrollPadding, 0), animated: true });
117
+ },
118
+ [scrollPadding]
119
+ );
120
+
121
+ const scrollProps = useMemo(
122
+ () => ({ onScroll, scrollEventThrottle: 16 }),
123
+ [onScroll]
124
+ );
125
+
126
+ return { ref, active, scrollProps, measure, scrollTo };
127
+ }