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
@@ -1,24 +1,104 @@
1
+ /**
2
+ * Tabs — segmented navigation between panels.
3
+ *
4
+ * The active tab is marked by one indicator that slides between measured
5
+ * trigger positions rather than by a style on each trigger. That is what makes
6
+ * the movement continuous: there is a single thing travelling, so a switch two
7
+ * tabs away reads as one gesture instead of two states swapping.
8
+ *
9
+ * ```tsx
10
+ * <Tabs defaultValue="account">
11
+ * <Tabs.List>
12
+ * <Tabs.Trigger value="account">Account</Tabs.Trigger>
13
+ * <Tabs.Trigger value="team" badge={<Badge>3</Badge>}>Team</Tabs.Trigger>
14
+ * </Tabs.List>
15
+ * <Tabs.Content value="account">…</Tabs.Content>
16
+ * </Tabs>
17
+ * ```
18
+ */
1
19
  import {
2
20
  createContext,
3
21
  useCallback,
4
22
  useContext,
5
23
  useEffect,
6
24
  useMemo,
25
+ useRef,
7
26
  useState,
8
27
  type ReactNode,
9
28
  } from 'react';
10
- import { Pressable, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
29
+ import {
30
+ Pressable,
31
+ ScrollView,
32
+ View,
33
+ type LayoutChangeEvent,
34
+ type ViewProps,
35
+ } from 'react-native';
11
36
  import Animated, {
12
37
  FadeIn,
13
38
  useAnimatedStyle,
14
39
  useSharedValue,
15
40
  withSpring,
16
41
  } from 'react-native-reanimated';
42
+ import { tv } from 'tailwind-variants';
17
43
  import { Text } from '../../primitives/text';
18
44
  import { cn } from '../../utils/cn';
19
45
 
20
46
  const SPRING = { damping: 24, stiffness: 300, mass: 0.7 } as const;
21
47
 
48
+ export type TabsVariant = 'segmented' | 'underline' | 'pill';
49
+
50
+ const tabsVariants = tv({
51
+ slots: {
52
+ list: 'flex-row',
53
+ indicator: 'absolute left-0',
54
+ trigger: 'items-center justify-center',
55
+ label: '',
56
+ },
57
+ variants: {
58
+ variant: {
59
+ // A raised chip travelling inside a recessed track.
60
+ segmented: {
61
+ list: 'rounded-lg bg-muted p-1',
62
+ indicator: 'bottom-1 top-1 rounded-md bg-popover shadow-sm',
63
+ trigger: 'rounded-md py-1.5',
64
+ },
65
+ // No track at all — the indicator is a rule under the active tab, and
66
+ // the row sits on a hairline so inactive tabs still have a baseline.
67
+ underline: {
68
+ list: 'gap-1 border-b border-border',
69
+ indicator: '-bottom-px h-0.5 rounded-full bg-foreground',
70
+ trigger: 'py-2.5',
71
+ },
72
+ // Filled chip on the page rather than in a track; the active label
73
+ // inverts against it.
74
+ pill: {
75
+ list: 'gap-1',
76
+ indicator: 'bottom-0 top-0 rounded-full bg-primary',
77
+ trigger: 'rounded-full py-2',
78
+ },
79
+ },
80
+ active: {
81
+ true: { label: 'text-foreground' },
82
+ false: { label: 'text-muted-foreground' },
83
+ },
84
+ disabled: {
85
+ true: { trigger: 'opacity-[0.44]' },
86
+ },
87
+ /** Intrinsic width in a scroller, equal shares when the row is fixed. */
88
+ scrollable: {
89
+ true: { trigger: 'px-4' },
90
+ false: { trigger: 'flex-1' },
91
+ },
92
+ },
93
+ compoundVariants: [
94
+ { variant: 'pill', active: true, class: { label: 'text-primary-foreground' } },
95
+ ],
96
+ defaultVariants: {
97
+ variant: 'segmented',
98
+ scrollable: false,
99
+ },
100
+ });
101
+
22
102
  interface TabLayout {
23
103
  x: number;
24
104
  width: number;
@@ -29,6 +109,10 @@ interface TabsContextValue {
29
109
  setValue: (value: string) => void;
30
110
  registerLayout: (value: string, layout: TabLayout) => void;
31
111
  layouts: Record<string, TabLayout>;
112
+ variant: TabsVariant;
113
+ scrollable: boolean;
114
+ setScrollable: (scrollable: boolean) => void;
115
+ keepMounted: boolean;
32
116
  }
33
117
 
34
118
  const TabsContext = createContext<TabsContextValue | null>(null);
@@ -46,6 +130,17 @@ export interface TabsProps extends ViewProps {
46
130
  value?: string;
47
131
  onValueChange?: (value: string) => void;
48
132
  defaultValue: string;
133
+ /**
134
+ * `segmented` is a chip travelling inside a recessed track, `underline` is a
135
+ * rule under the active tab, `pill` is a filled chip on the page.
136
+ */
137
+ variant?: TabsVariant;
138
+ /**
139
+ * Keep inactive panels mounted and hidden instead of unmounting them, so a
140
+ * scroll position or a half-filled form survives a switch away and back.
141
+ * Costs the render of every panel up front.
142
+ */
143
+ keepMounted?: boolean;
49
144
  children: ReactNode;
50
145
  }
51
146
 
@@ -54,11 +149,16 @@ function TabsRoot({
54
149
  value,
55
150
  onValueChange,
56
151
  defaultValue,
152
+ variant = 'segmented',
153
+ keepMounted = false,
57
154
  children,
58
155
  ...props
59
156
  }: TabsProps) {
60
157
  const [internalValue, setInternalValue] = useState(defaultValue);
61
158
  const [layouts, setLayouts] = useState<Record<string, TabLayout>>({});
159
+ // Published by the List rather than the root, because it is the List that
160
+ // decides whether it scrolls — but the Triggers below it need to know.
161
+ const [scrollable, setScrollable] = useState(false);
62
162
  const isControlled = value !== undefined;
63
163
  const resolvedValue = isControlled ? value : internalValue;
64
164
 
@@ -81,8 +181,17 @@ function TabsRoot({
81
181
  }, []);
82
182
 
83
183
  const context = useMemo(
84
- () => ({ value: resolvedValue, setValue, registerLayout, layouts }),
85
- [resolvedValue, setValue, registerLayout, layouts]
184
+ () => ({
185
+ value: resolvedValue,
186
+ setValue,
187
+ registerLayout,
188
+ layouts,
189
+ variant,
190
+ scrollable,
191
+ setScrollable,
192
+ keepMounted,
193
+ }),
194
+ [resolvedValue, setValue, registerLayout, layouts, variant, scrollable, keepMounted]
86
195
  );
87
196
 
88
197
  return (
@@ -95,12 +204,13 @@ function TabsRoot({
95
204
  }
96
205
 
97
206
  function TabsIndicator() {
98
- const { value, layouts } = useTabs('Tabs.List');
207
+ const { value, layouts, variant } = useTabs('Tabs.List');
99
208
  const x = useSharedValue(0);
100
209
  const width = useSharedValue(0);
101
210
  const initialized = useSharedValue(0);
102
211
 
103
212
  const layout = layouts[value];
213
+ const { indicator } = tabsVariants({ variant });
104
214
 
105
215
  // In an effect, not the render body: touching a shared value during render
106
216
  // is a Reanimated strict-mode violation, and the write can be lost or
@@ -125,41 +235,93 @@ function TabsIndicator() {
125
235
  width: width.value,
126
236
  }));
127
237
 
128
- return (
129
- <Animated.View
130
- style={style}
131
- className="absolute bottom-1 top-1 left-0 rounded-md bg-popover shadow-sm"
132
- />
133
- );
238
+ return <Animated.View style={style} className={indicator()} />;
134
239
  }
135
240
 
136
241
  export interface TabsListProps extends ViewProps {
137
242
  className?: string;
243
+ /**
244
+ * Lay the triggers out at their natural widths inside a horizontal scroller
245
+ * instead of splitting the row between them. For more tabs than fit — which
246
+ * a fixed row answers by crushing every label.
247
+ */
248
+ scrollable?: boolean;
138
249
  children: ReactNode;
139
250
  }
140
251
 
141
- function TabsList({ className, children, ...props }: TabsListProps) {
142
- return (
143
- <View
144
- accessibilityRole="tablist"
145
- className={cn('flex-row rounded-lg bg-muted p-1', className)}
146
- {...props}
147
- >
252
+ function TabsList({ className, scrollable = false, children, ...props }: TabsListProps) {
253
+ const { variant, setScrollable, value, layouts } = useTabs('Tabs.List');
254
+ const { list } = tabsVariants({ variant });
255
+ const scroller = useRef<ScrollView>(null);
256
+
257
+ useEffect(() => {
258
+ setScrollable(scrollable);
259
+ }, [scrollable, setScrollable]);
260
+
261
+ // Bring the active tab into view when it changes from elsewhere — a
262
+ // controlled switch, or a swipe on the panel below.
263
+ const activeLayout = layouts[value];
264
+ useEffect(() => {
265
+ if (!scrollable || !activeLayout) return;
266
+ scroller.current?.scrollTo({
267
+ // Land the tab a little in from the edge rather than flush against it,
268
+ // so it does not read as the last one in the row.
269
+ x: Math.max(activeLayout.x - 24, 0),
270
+ animated: true,
271
+ });
272
+ }, [scrollable, activeLayout?.x, activeLayout]);
273
+
274
+ const row = (
275
+ <View accessibilityRole="tablist" className={cn(list(), className)} {...props}>
148
276
  <TabsIndicator />
149
277
  {children}
150
278
  </View>
151
279
  );
280
+
281
+ if (!scrollable) return row;
282
+
283
+ return (
284
+ <ScrollView
285
+ ref={scroller}
286
+ horizontal
287
+ showsHorizontalScrollIndicator={false}
288
+ // The row measures itself, and the indicator is positioned against it,
289
+ // so the scroller must not stretch it to the viewport width.
290
+ contentContainerStyle={{ flexGrow: 0 }}
291
+ >
292
+ {row}
293
+ </ScrollView>
294
+ );
152
295
  }
153
296
 
154
297
  export interface TabsTriggerProps {
155
298
  className?: string;
156
299
  value: string;
300
+ /** Rendered before the label. */
301
+ icon?: ReactNode;
302
+ /** Rendered after the label — a count, a dot, a status. */
303
+ badge?: ReactNode;
304
+ /** Unselectable, dimmed, and announced as disabled. */
305
+ disabled?: boolean;
157
306
  children: ReactNode;
158
307
  }
159
308
 
160
- function TabsTrigger({ className, value, children }: TabsTriggerProps) {
309
+ function TabsTrigger({
310
+ className,
311
+ value,
312
+ icon,
313
+ badge,
314
+ disabled = false,
315
+ children,
316
+ }: TabsTriggerProps) {
161
317
  const context = useTabs('Tabs.Trigger');
162
318
  const active = context.value === value;
319
+ const slots = tabsVariants({
320
+ variant: context.variant,
321
+ active,
322
+ disabled,
323
+ scrollable: context.scrollable,
324
+ });
163
325
 
164
326
  const handleLayout = useCallback(
165
327
  (event: LayoutChangeEvent) => {
@@ -172,22 +334,21 @@ function TabsTrigger({ className, value, children }: TabsTriggerProps) {
172
334
  return (
173
335
  <Pressable
174
336
  accessibilityRole="tab"
175
- accessibilityState={{ selected: active }}
337
+ accessibilityState={{ selected: active, disabled }}
338
+ disabled={disabled}
176
339
  onPress={() => context.setValue(value)}
177
340
  onLayout={handleLayout}
178
- className={cn('flex-1 items-center justify-center rounded-md py-1.5', className)}
341
+ className={cn(slots.trigger(), (icon || badge) && 'flex-row gap-1.5', className)}
179
342
  >
343
+ {icon}
180
344
  {typeof children === 'string' ? (
181
- <Text
182
- size="sm"
183
- weight="medium"
184
- className={active ? 'text-foreground' : 'text-muted-foreground'}
185
- >
345
+ <Text size="sm" weight="medium" className={slots.label()}>
186
346
  {children}
187
347
  </Text>
188
348
  ) : (
189
349
  children
190
350
  )}
351
+ {badge}
191
352
  </Pressable>
192
353
  );
193
354
  }
@@ -198,12 +359,31 @@ export interface TabsContentProps extends ViewProps {
198
359
  children: ReactNode;
199
360
  }
200
361
 
201
- function TabsContent({ className, value, children, ...props }: TabsContentProps) {
362
+ function TabsContent({ className, value, children, style, ...props }: TabsContentProps) {
202
363
  const context = useTabs('Tabs.Content');
203
- if (context.value !== value) return null;
364
+ const active = context.value === value;
204
365
 
366
+ if (!active && !context.keepMounted) return null;
367
+
368
+ /*
369
+ * Hidden rather than unmounted under `keepMounted`, and hidden thoroughly:
370
+ * `display: none` takes it out of layout, and the accessibility props take
371
+ * it out of the reading order too. A screen reader walking through three
372
+ * panels of a tab set it cannot see is worse than no tabs at all.
373
+ */
205
374
  return (
206
- <Animated.View entering={FadeIn.duration(150)} className={className} {...props}>
375
+ <Animated.View
376
+ // Only worth animating when the panel is genuinely arriving. A kept
377
+ // panel is already there; fading it in every time it is revealed would
378
+ // undo the point of keeping it.
379
+ entering={context.keepMounted ? undefined : FadeIn.duration(150)}
380
+ style={[!active && { display: 'none' }, style]}
381
+ pointerEvents={active ? 'auto' : 'none'}
382
+ accessibilityElementsHidden={!active}
383
+ importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}
384
+ className={className}
385
+ {...props}
386
+ >
207
387
  {children}
208
388
  </Animated.View>
209
389
  );