panelui-native 0.44.0 → 0.49.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.
- package/README.md +6 -1
- package/lib/module/components/accordion/index.js +32 -4
- package/lib/module/components/accordion/index.js.map +1 -1
- package/lib/module/components/button/index.js +83 -15
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/button-group/index.js +186 -0
- package/lib/module/components/button-group/index.js.map +1 -0
- package/lib/module/components/color-picker/index.js +110 -1
- package/lib/module/components/color-picker/index.js.map +1 -1
- package/lib/module/components/combobox/index.js +7 -1
- package/lib/module/components/combobox/index.js.map +1 -1
- package/lib/module/components/date-time-picker/index.js +272 -0
- package/lib/module/components/date-time-picker/index.js.map +1 -0
- package/lib/module/components/fab/index.js +514 -0
- package/lib/module/components/fab/index.js.map +1 -0
- package/lib/module/components/grid-item/index.js +486 -0
- package/lib/module/components/grid-item/index.js.map +1 -0
- package/lib/module/components/{kpi-chart → kpi}/index.js +62 -62
- package/lib/module/components/kpi/index.js.map +1 -0
- package/lib/module/components/line-chart/index.js +92 -10
- package/lib/module/components/line-chart/index.js.map +1 -1
- package/lib/module/components/markdown-editor/index.js +406 -0
- package/lib/module/components/markdown-editor/index.js.map +1 -0
- package/lib/module/components/markdown-editor/markdown-transforms.js +243 -0
- package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -0
- package/lib/module/components/pie-chart/index.js +625 -0
- package/lib/module/components/pie-chart/index.js.map +1 -0
- package/lib/module/components/questionnaire/index.js +1312 -0
- package/lib/module/components/questionnaire/index.js.map +1 -0
- package/lib/module/components/scatter-chart/index.js +1173 -0
- package/lib/module/components/scatter-chart/index.js.map +1 -0
- package/lib/module/components/tabs/index.js +359 -41
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/components/time-picker/index.js +34 -6
- package/lib/module/components/time-picker/index.js.map +1 -1
- package/lib/module/components/tree/index.js +500 -0
- package/lib/module/components/tree/index.js.map +1 -0
- package/lib/module/icons/index.js +217 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +11 -2
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/chart.js +81 -0
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/typescript/src/components/accordion/index.d.ts +21 -0
- package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +21 -0
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button-group/index.d.ts +212 -0
- package/lib/typescript/src/components/button-group/index.d.ts.map +1 -0
- package/lib/typescript/src/components/color-picker/index.d.ts +82 -1
- package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/date-time-picker/index.d.ts +127 -0
- package/lib/typescript/src/components/date-time-picker/index.d.ts.map +1 -0
- package/lib/typescript/src/components/fab/index.d.ts +285 -0
- package/lib/typescript/src/components/fab/index.d.ts.map +1 -0
- package/lib/typescript/src/components/grid-item/index.d.ts +292 -0
- package/lib/typescript/src/components/grid-item/index.d.ts.map +1 -0
- package/lib/typescript/src/components/{kpi-chart → kpi}/index.d.ts +65 -65
- package/lib/typescript/src/components/kpi/index.d.ts.map +1 -0
- package/lib/typescript/src/components/line-chart/index.d.ts +25 -1
- package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/markdown-editor/index.d.ts +102 -0
- package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -0
- package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +76 -0
- package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -0
- package/lib/typescript/src/components/pie-chart/index.d.ts +245 -0
- package/lib/typescript/src/components/pie-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/questionnaire/index.d.ts +336 -0
- package/lib/typescript/src/components/questionnaire/index.d.ts.map +1 -0
- package/lib/typescript/src/components/scatter-chart/index.d.ts +309 -0
- package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/tabs/index.d.ts +29 -2
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/components/time-picker/index.d.ts +19 -1
- package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tree/index.d.ts +125 -0
- package/lib/typescript/src/components/tree/index.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +22 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +14 -5
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +34 -0
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/accordion/index.tsx +48 -6
- package/src/components/button/index.tsx +97 -15
- package/src/components/button-group/index.tsx +199 -0
- package/src/components/color-picker/index.tsx +140 -3
- package/src/components/combobox/index.tsx +7 -1
- package/src/components/date-time-picker/index.tsx +411 -0
- package/src/components/fab/index.tsx +583 -0
- package/src/components/grid-item/index.tsx +515 -0
- package/src/components/{kpi-chart → kpi}/index.tsx +85 -85
- package/src/components/line-chart/index.tsx +98 -8
- package/src/components/markdown-editor/index.tsx +526 -0
- package/src/components/markdown-editor/markdown-transforms.ts +228 -0
- package/src/components/pie-chart/index.tsx +863 -0
- package/src/components/questionnaire/index.tsx +1615 -0
- package/src/components/scatter-chart/index.tsx +1401 -0
- package/src/components/tabs/index.tsx +392 -50
- package/src/components/time-picker/index.tsx +42 -6
- package/src/components/tree/index.tsx +564 -0
- package/src/icons/index.tsx +154 -0
- package/src/index.ts +142 -18
- package/src/utils/chart.ts +110 -0
- package/lib/module/components/kpi-chart/index.js.map +0 -1
- package/lib/typescript/src/components/kpi-chart/index.d.ts.map +0 -1
|
@@ -15,6 +15,15 @@
|
|
|
15
15
|
* <Tabs.Content value="account">…</Tabs.Content>
|
|
16
16
|
* </Tabs>
|
|
17
17
|
* ```
|
|
18
|
+
*
|
|
19
|
+
* **Swiping.** Only the visible panel is mounted, so a drag has nothing behind
|
|
20
|
+
* it to reveal. The panel still tracks the finger one to one and dims as it
|
|
21
|
+
* goes, and the panel arriving comes back up through the same fade — the
|
|
22
|
+
* movement carries the change and the dissolve covers the gap. On release the
|
|
23
|
+
* arriving panel picks up a whole panel's width from wherever the outgoing one
|
|
24
|
+
* was let go, so the two views draw one continuous movement between them. The
|
|
25
|
+
* displacement lives on the root for exactly that reason: a value belonging to
|
|
26
|
+
* either panel would be destroyed at the handover.
|
|
18
27
|
*/
|
|
19
28
|
import {
|
|
20
29
|
createContext,
|
|
@@ -27,6 +36,7 @@ import {
|
|
|
27
36
|
type ReactNode,
|
|
28
37
|
} from 'react';
|
|
29
38
|
import {
|
|
39
|
+
Platform,
|
|
30
40
|
Pressable,
|
|
31
41
|
ScrollView,
|
|
32
42
|
View,
|
|
@@ -35,11 +45,16 @@ import {
|
|
|
35
45
|
} from 'react-native';
|
|
36
46
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
37
47
|
import Animated, {
|
|
48
|
+
Easing,
|
|
49
|
+
Extrapolation,
|
|
38
50
|
FadeIn,
|
|
51
|
+
interpolate,
|
|
39
52
|
runOnJS,
|
|
40
53
|
useAnimatedStyle,
|
|
54
|
+
useReducedMotion,
|
|
41
55
|
useSharedValue,
|
|
42
56
|
withSpring,
|
|
57
|
+
withTiming,
|
|
43
58
|
type SharedValue,
|
|
44
59
|
} from 'react-native-reanimated';
|
|
45
60
|
import { tv } from 'tailwind-variants';
|
|
@@ -64,32 +79,66 @@ const SWIPE_DISTANCE_RATIO = 0.25;
|
|
|
64
79
|
const SWIPE_VELOCITY = 500;
|
|
65
80
|
|
|
66
81
|
/**
|
|
67
|
-
* How much of the finger's travel the panel
|
|
82
|
+
* How much of the finger's travel the panel follows at the ends of the row,
|
|
83
|
+
* where there is nowhere for it to go. Everywhere else it follows all of it.
|
|
84
|
+
*/
|
|
85
|
+
const SWIPE_RESISTANCE_AT_END = 0.16;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* How far the panel fades as it is dragged, at a full panel's width.
|
|
89
|
+
*
|
|
90
|
+
* Tracking the finger one to one leaves the space behind the panel empty,
|
|
91
|
+
* because the panels are separate views and only the visible one is mounted.
|
|
92
|
+
* Dimming as it goes is what keeps that from reading as a hole punched in the
|
|
93
|
+
* card: a panel on its way out is on its way out, and the one arriving comes
|
|
94
|
+
* back up through the same fade. The two together read as a dissolve carried
|
|
95
|
+
* by the movement, rather than as a gap between two slides.
|
|
68
96
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
97
|
+
* **iOS only, and that is not a compromise.** A transform is a property of the
|
|
98
|
+
* layer the panel is drawn into, so moving it costs nothing per frame however
|
|
99
|
+
* much is inside it. An opacity that changes every frame is not: on Android the
|
|
100
|
+
* panel's view group has no offscreen buffer to fade, so the alpha is pushed
|
|
101
|
+
* down into its children and the whole visible subtree is re-drawn on every
|
|
102
|
+
* frame of the drag. Behind a list that is thirty rows of text and images,
|
|
103
|
+
* which is what a tab panel usually is, that is the difference between a swipe
|
|
104
|
+
* that tracks the finger and one that stutters. iOS fades the layer itself and
|
|
105
|
+
* is unaffected.
|
|
73
106
|
*/
|
|
74
|
-
const
|
|
75
|
-
/** …and a fraction of that again at the ends, where there is nowhere to go. */
|
|
76
|
-
const SWIPE_FOLLOW_AT_END = 0.1;
|
|
107
|
+
const SWIPE_FADE = Platform.OS === 'ios' ? 0.75 : 0;
|
|
77
108
|
|
|
78
109
|
/**
|
|
79
|
-
* How far off its resting place
|
|
80
|
-
* panel's width.
|
|
110
|
+
* How far off its resting place a panel arriving by a *press* starts, as a
|
|
111
|
+
* share of the panel's width.
|
|
81
112
|
*
|
|
82
|
-
* A
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* roughly where the outgoing one was let go, on the far side, and travels in.
|
|
113
|
+
* A press has no finger travel to continue from, so it is given a throw of its
|
|
114
|
+
* own. A swipe does not use this: the panel it hands over to picks up exactly
|
|
115
|
+
* one width from wherever the outgoing one has travelled to — see `setValue`.
|
|
86
116
|
*/
|
|
87
117
|
const SWIPE_ENTER = 0.3;
|
|
88
118
|
|
|
89
119
|
/** The arriving panel is springier than the one being dragged back into place. */
|
|
90
120
|
const ENTER_SPRING = { damping: 22, stiffness: 240, mass: 0.6 } as const;
|
|
91
121
|
|
|
92
|
-
|
|
122
|
+
/** How far the label's reveal is from its own width, in points — the gap after the icon. */
|
|
123
|
+
const LABEL_GAP = 6;
|
|
124
|
+
|
|
125
|
+
/** Points of room over the measured label, so a rounding error cannot truncate it. */
|
|
126
|
+
const LABEL_SLACK = 2;
|
|
127
|
+
|
|
128
|
+
/** Milliseconds for a tab to open its label, and for the one before it to close. */
|
|
129
|
+
const EXPAND_DURATION = 260;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A width the ghost label is measured inside.
|
|
133
|
+
*
|
|
134
|
+
* A `Text` measures against the box it is in, and the box it is really in is a
|
|
135
|
+
* pill whose width is the thing being measured. So the copy that gets measured
|
|
136
|
+
* sits in a box wide enough not to be the constraint, and reports the width the
|
|
137
|
+
* label actually wants.
|
|
138
|
+
*/
|
|
139
|
+
const MEASURE_WIDTH = 400;
|
|
140
|
+
|
|
141
|
+
export type TabsVariant = 'segmented' | 'underline' | 'pill' | 'expanding';
|
|
93
142
|
|
|
94
143
|
const tabsVariants = tv({
|
|
95
144
|
slots: {
|
|
@@ -120,6 +169,22 @@ const tabsVariants = tv({
|
|
|
120
169
|
indicator: 'bottom-0 top-0 rounded-full bg-primary',
|
|
121
170
|
trigger: 'rounded-full py-2',
|
|
122
171
|
},
|
|
172
|
+
/*
|
|
173
|
+
* A row of icon pills, one of which is open.
|
|
174
|
+
*
|
|
175
|
+
* Every tab draws its own pill here, so there is no indicator to slide
|
|
176
|
+
* between them — the shape that moves is the open tab itself, widening
|
|
177
|
+
* to let its label out and closing again behind it. An indicator sliding
|
|
178
|
+
* under pills that already have backgrounds would be invisible anyway.
|
|
179
|
+
*
|
|
180
|
+
* The open pill is a step further from the page than the closed ones:
|
|
181
|
+
* lighter in a dark theme, darker in a light one, which is what the
|
|
182
|
+
* tertiary surface token means and why it is used rather than a colour.
|
|
183
|
+
*/
|
|
184
|
+
expanding: {
|
|
185
|
+
list: 'gap-1.5',
|
|
186
|
+
trigger: 'rounded-full bg-muted px-3.5 py-2.5',
|
|
187
|
+
},
|
|
123
188
|
},
|
|
124
189
|
active: {
|
|
125
190
|
true: { label: 'text-foreground' },
|
|
@@ -136,6 +201,11 @@ const tabsVariants = tv({
|
|
|
136
201
|
},
|
|
137
202
|
compoundVariants: [
|
|
138
203
|
{ variant: 'pill', active: true, class: { label: 'text-primary-foreground' } },
|
|
204
|
+
// A pill is as wide as what is inside it. Equal shares would give every
|
|
205
|
+
// closed tab the width of the open one, which is the layout this variant
|
|
206
|
+
// exists to avoid.
|
|
207
|
+
{ variant: 'expanding', class: { trigger: 'flex-none' } },
|
|
208
|
+
{ variant: 'expanding', active: true, class: { trigger: 'bg-surface-tertiary' } },
|
|
139
209
|
],
|
|
140
210
|
defaultVariants: {
|
|
141
211
|
variant: 'segmented',
|
|
@@ -148,9 +218,19 @@ interface TabLayout {
|
|
|
148
218
|
width: number;
|
|
149
219
|
}
|
|
150
220
|
|
|
221
|
+
/**
|
|
222
|
+
* What a swipe hands over to the tab it commits to: where the finger left the
|
|
223
|
+
* outgoing panel, and how fast it was still going. Absent when the tab was
|
|
224
|
+
* changed by pressing a trigger, which has neither.
|
|
225
|
+
*/
|
|
226
|
+
interface SwipeHandover {
|
|
227
|
+
/** Points per second at the moment of release, in the panel's own axis. */
|
|
228
|
+
velocity: number;
|
|
229
|
+
}
|
|
230
|
+
|
|
151
231
|
interface TabsContextValue {
|
|
152
232
|
value: string;
|
|
153
|
-
setValue: (value: string) => void;
|
|
233
|
+
setValue: (value: string, handover?: SwipeHandover) => void;
|
|
154
234
|
registerLayout: (value: string, layout: TabLayout) => void;
|
|
155
235
|
layouts: Record<string, TabLayout>;
|
|
156
236
|
/**
|
|
@@ -200,6 +280,12 @@ export interface TabsProps extends ViewProps {
|
|
|
200
280
|
/**
|
|
201
281
|
* `segmented` is a chip travelling inside a recessed track, `underline` is a
|
|
202
282
|
* rule under the active tab, `pill` is a filled chip on the page.
|
|
283
|
+
*
|
|
284
|
+
* `expanding` is a row of icon pills where only the selected one is open:
|
|
285
|
+
* it widens to let its label out and closes again behind it. For a short row
|
|
286
|
+
* of destinations that are recognisable by their icons, where the labels
|
|
287
|
+
* would otherwise take the whole width to say things nobody rereads. Give
|
|
288
|
+
* every trigger an `icon` — a closed tab has nothing else.
|
|
203
289
|
*/
|
|
204
290
|
variant?: TabsVariant;
|
|
205
291
|
/**
|
|
@@ -216,6 +302,15 @@ export interface TabsProps extends ViewProps {
|
|
|
216
302
|
* already wants a horizontal drag — a carousel, a slider, a row that swipes
|
|
217
303
|
* open — and the two cannot both have it. Turn it on for panels of ordinary
|
|
218
304
|
* scrolling content, where it is the gesture people try first.
|
|
305
|
+
*
|
|
306
|
+
* **It does not change what is mounted.** Only `keepMounted` decides that,
|
|
307
|
+
* with or without this — a swipe animates between two panels of which one is
|
|
308
|
+
* being unmounted and the other mounted for the first time, exactly as a
|
|
309
|
+
* press does. What it does change is that the mount now happens *while
|
|
310
|
+
* something is moving*, so a panel that is slow to build stops being a pause
|
|
311
|
+
* before it appears and starts being a stutter in the movement. If a swipe
|
|
312
|
+
* feels heavier than a press on the same tab set, the panel is expensive to
|
|
313
|
+
* mount; `keepMounted` is the answer, not turning this off.
|
|
219
314
|
*/
|
|
220
315
|
swipeable?: boolean;
|
|
221
316
|
children: ReactNode;
|
|
@@ -244,8 +339,26 @@ function TabsRoot({
|
|
|
244
339
|
const isControlled = value !== undefined;
|
|
245
340
|
const resolvedValue = isControlled ? value : internalValue;
|
|
246
341
|
|
|
342
|
+
/*
|
|
343
|
+
* The two things `setValue` reads that change on every switch, held where
|
|
344
|
+
* reading them does not make it a new function.
|
|
345
|
+
*
|
|
346
|
+
* With them in the dependency list, changing tab produced a new `setValue`,
|
|
347
|
+
* therefore a new context, therefore a new `step`, therefore a new pan
|
|
348
|
+
* gesture — which detaches and re-attaches the native recogniser on the same
|
|
349
|
+
* commit that mounts the arriving panel's contents. The busiest frame of the
|
|
350
|
+
* interaction was doing the one piece of work that had no reason to be there.
|
|
351
|
+
*/
|
|
352
|
+
const tabsRef = useRef(tabs);
|
|
353
|
+
tabsRef.current = tabs;
|
|
354
|
+
const valueRef = useRef(resolvedValue);
|
|
355
|
+
valueRef.current = resolvedValue;
|
|
356
|
+
|
|
247
357
|
const setValue = useCallback(
|
|
248
|
-
(next: string) => {
|
|
358
|
+
(next: string, handover?: SwipeHandover) => {
|
|
359
|
+
const tabs = tabsRef.current;
|
|
360
|
+
const resolvedValue = valueRef.current;
|
|
361
|
+
|
|
249
362
|
/*
|
|
250
363
|
* The arriving panel starts on the side it is arriving from, and travels
|
|
251
364
|
* in. Done here rather than in the panel because only the root knows
|
|
@@ -258,25 +371,37 @@ function TabsRoot({
|
|
|
258
371
|
const from = tabs.indexOf(resolvedValue);
|
|
259
372
|
const to = tabs.indexOf(next);
|
|
260
373
|
if (from !== -1 && to !== -1 && from !== to) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
374
|
+
const direction = to > from ? 1 : -1;
|
|
375
|
+
|
|
376
|
+
if (handover) {
|
|
377
|
+
/*
|
|
378
|
+
* A swipe hands over mid-movement, so the arriving panel starts a
|
|
379
|
+
* whole panel's width from wherever the outgoing one has got to —
|
|
380
|
+
* which is where it *would* have been all along, had both been
|
|
381
|
+
* mounted. Read live rather than captured, because the outgoing
|
|
382
|
+
* panel is still travelling while this runs: adding to the current
|
|
383
|
+
* displacement rather than replacing it is the whole difference
|
|
384
|
+
* between one continuous movement and a jump at the moment the
|
|
385
|
+
* arriving panel finishes mounting.
|
|
386
|
+
*/
|
|
387
|
+
swipeOffset.value += direction * sign * panelWidth.value;
|
|
388
|
+
swipeOffset.value = withSpring(0, {
|
|
389
|
+
...ENTER_SPRING,
|
|
390
|
+
// Carried across too, so a flick keeps its speed instead of
|
|
391
|
+
// stopping dead and starting again from rest.
|
|
392
|
+
velocity: handover.velocity,
|
|
393
|
+
});
|
|
394
|
+
} else {
|
|
395
|
+
swipeOffset.value = direction * sign * panelWidth.value * SWIPE_ENTER;
|
|
396
|
+
swipeOffset.value = withSpring(0, ENTER_SPRING);
|
|
397
|
+
}
|
|
264
398
|
}
|
|
265
399
|
}
|
|
266
400
|
|
|
267
401
|
if (!isControlled) setInternalValue(next);
|
|
268
402
|
onValueChange?.(next);
|
|
269
403
|
},
|
|
270
|
-
[
|
|
271
|
-
isControlled,
|
|
272
|
-
onValueChange,
|
|
273
|
-
swipeable,
|
|
274
|
-
tabs,
|
|
275
|
-
resolvedValue,
|
|
276
|
-
sign,
|
|
277
|
-
swipeOffset,
|
|
278
|
-
panelWidth,
|
|
279
|
-
]
|
|
404
|
+
[isControlled, onValueChange, swipeable, sign, swipeOffset, panelWidth]
|
|
280
405
|
);
|
|
281
406
|
|
|
282
407
|
const registerLayout = useCallback((tab: string, layout: TabLayout) => {
|
|
@@ -414,7 +539,9 @@ function TabsList({ className, scrollable = false, children, ...props }: TabsLis
|
|
|
414
539
|
|
|
415
540
|
const row = (
|
|
416
541
|
<View accessibilityRole="tablist" className={cn(list(), className)} {...props}>
|
|
417
|
-
|
|
542
|
+
{/* Nothing to slide: in `expanding` every tab draws its own pill, and the
|
|
543
|
+
open one is the shape that moves. */}
|
|
544
|
+
{variant === 'expanding' ? null : <TabsIndicator />}
|
|
418
545
|
{textChildren(children)}
|
|
419
546
|
</View>
|
|
420
547
|
);
|
|
@@ -438,7 +565,10 @@ function TabsList({ className, scrollable = false, children, ...props }: TabsLis
|
|
|
438
565
|
export interface TabsTriggerProps {
|
|
439
566
|
className?: string;
|
|
440
567
|
value: string;
|
|
441
|
-
/**
|
|
568
|
+
/**
|
|
569
|
+
* Rendered before the label. Required by `variant="expanding"`, where it is
|
|
570
|
+
* the only thing a closed tab has left to identify it by.
|
|
571
|
+
*/
|
|
442
572
|
icon?: ReactNode;
|
|
443
573
|
/** Rendered after the label — a count, a dot, a status. */
|
|
444
574
|
badge?: ReactNode;
|
|
@@ -481,6 +611,23 @@ function TabsTrigger({
|
|
|
481
611
|
return () => unregisterTab(value);
|
|
482
612
|
}, [value, registerTab, unregisterTab]);
|
|
483
613
|
|
|
614
|
+
if (context.variant === 'expanding') {
|
|
615
|
+
return (
|
|
616
|
+
<ExpandingTrigger
|
|
617
|
+
active={active}
|
|
618
|
+
disabled={disabled}
|
|
619
|
+
icon={icon}
|
|
620
|
+
badge={badge}
|
|
621
|
+
labelClassName={slots.label()}
|
|
622
|
+
className={cn(slots.trigger(), className)}
|
|
623
|
+
onLayout={handleLayout}
|
|
624
|
+
onPress={() => context.setValue(value)}
|
|
625
|
+
>
|
|
626
|
+
{children}
|
|
627
|
+
</ExpandingTrigger>
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
484
631
|
return (
|
|
485
632
|
<Pressable
|
|
486
633
|
accessibilityRole="tab"
|
|
@@ -501,6 +648,142 @@ function TabsTrigger({
|
|
|
501
648
|
);
|
|
502
649
|
}
|
|
503
650
|
|
|
651
|
+
/**
|
|
652
|
+
* A tab that is an icon until it is selected, and then an icon and its label.
|
|
653
|
+
*
|
|
654
|
+
* The label is never unmounted, only closed over. Two reasons, and both matter:
|
|
655
|
+
* a screen reader walking a row of unlabelled icons has nothing to read out,
|
|
656
|
+
* and a label that mounts on selection has no width to animate *from*, so the
|
|
657
|
+
* pill would jump to its open size and the text would fade in inside it.
|
|
658
|
+
*
|
|
659
|
+
* So the width is animated instead, which needs a number — and the number is
|
|
660
|
+
* the one thing that cannot be measured in place, because the box the label
|
|
661
|
+
* sits in is the box being resized. A second copy, laid out once in a box wide
|
|
662
|
+
* enough not to constrain it, reports the width and is never seen.
|
|
663
|
+
*/
|
|
664
|
+
function ExpandingTrigger({
|
|
665
|
+
active,
|
|
666
|
+
disabled,
|
|
667
|
+
icon,
|
|
668
|
+
badge,
|
|
669
|
+
className,
|
|
670
|
+
labelClassName,
|
|
671
|
+
onLayout,
|
|
672
|
+
onPress,
|
|
673
|
+
children,
|
|
674
|
+
}: {
|
|
675
|
+
active: boolean;
|
|
676
|
+
disabled: boolean;
|
|
677
|
+
icon?: ReactNode;
|
|
678
|
+
badge?: ReactNode;
|
|
679
|
+
className: string;
|
|
680
|
+
labelClassName: string;
|
|
681
|
+
onLayout: (event: LayoutChangeEvent) => void;
|
|
682
|
+
onPress: () => void;
|
|
683
|
+
children: ReactNode;
|
|
684
|
+
}) {
|
|
685
|
+
const [labelWidth, setLabelWidth] = useState(0);
|
|
686
|
+
const reducedMotion = useReducedMotion();
|
|
687
|
+
const open = useSharedValue(active ? 1 : 0);
|
|
688
|
+
|
|
689
|
+
useEffect(() => {
|
|
690
|
+
if (reducedMotion) {
|
|
691
|
+
open.value = active ? 1 : 0;
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
/*
|
|
695
|
+
* A curve, not a spring.
|
|
696
|
+
*
|
|
697
|
+
* This animates a *width*, so every frame of it is a layout pass — and the
|
|
698
|
+
* row is centred, so every other pill moves with it. A spring overshoots
|
|
699
|
+
* past its target and settles back, which on a box that is clipping text
|
|
700
|
+
* means the last word slides in, out and in again, and the whole row
|
|
701
|
+
* wobbles with it. The curve leaves quickly and arrives slowly, and it
|
|
702
|
+
* arrives once.
|
|
703
|
+
*/
|
|
704
|
+
open.value = withTiming(active ? 1 : 0, {
|
|
705
|
+
duration: EXPAND_DURATION,
|
|
706
|
+
easing: Easing.bezier(0.2, 0, 0, 1),
|
|
707
|
+
});
|
|
708
|
+
}, [active, reducedMotion, open]);
|
|
709
|
+
|
|
710
|
+
const reveal = useAnimatedStyle(() => ({
|
|
711
|
+
width: (labelWidth + LABEL_GAP) * open.value,
|
|
712
|
+
/*
|
|
713
|
+
* Behind the width, not ahead of it. Text drawn into a box narrower than
|
|
714
|
+
* itself is text with its end cut off, and doing that on purpose for the
|
|
715
|
+
* first half of the animation is the difference between a label arriving
|
|
716
|
+
* and a label being wiped on.
|
|
717
|
+
*/
|
|
718
|
+
opacity: interpolate(open.value, [0.35, 0.9], [0, 1], Extrapolation.CLAMP),
|
|
719
|
+
}));
|
|
720
|
+
|
|
721
|
+
const label = textChildren(children, (text) => (
|
|
722
|
+
<Text size="sm" weight="medium" numberOfLines={1} className={labelClassName}>
|
|
723
|
+
{text}
|
|
724
|
+
</Text>
|
|
725
|
+
));
|
|
726
|
+
|
|
727
|
+
return (
|
|
728
|
+
<Pressable
|
|
729
|
+
accessibilityRole="tab"
|
|
730
|
+
accessibilityState={{ selected: active, disabled }}
|
|
731
|
+
// The label may be closed to nothing, and a tab announced as an unlabelled
|
|
732
|
+
// icon is a tab nobody can choose.
|
|
733
|
+
accessibilityLabel={typeof children === 'string' ? children : undefined}
|
|
734
|
+
disabled={disabled}
|
|
735
|
+
onPress={onPress}
|
|
736
|
+
onLayout={onLayout}
|
|
737
|
+
className={cn('flex-row items-center', className)}
|
|
738
|
+
>
|
|
739
|
+
{icon}
|
|
740
|
+
<Animated.View style={[{ overflow: 'hidden' }, reveal]}>
|
|
741
|
+
{/*
|
|
742
|
+
* Pinned to the measured width so the text does not reflow as the box
|
|
743
|
+
* around it closes — a label that rewraps on its way out reads as a
|
|
744
|
+
* glitch rather than as a reveal.
|
|
745
|
+
*
|
|
746
|
+
* The gap after the icon is padding on this box, so it has to be added
|
|
747
|
+
* to the width rather than taken out of it. Set to the measured width
|
|
748
|
+
* alone, the padding comes off the inside and the label is handed six
|
|
749
|
+
* points less than it asked for, which a single-line `Text` answers by
|
|
750
|
+
* truncating: "Inbox" arrives as "Inbo…" and stays that way.
|
|
751
|
+
*/}
|
|
752
|
+
<View style={{ width: labelWidth + LABEL_GAP, paddingStart: LABEL_GAP }}>
|
|
753
|
+
{label}
|
|
754
|
+
</View>
|
|
755
|
+
</Animated.View>
|
|
756
|
+
{/* Measured once, never seen, and out of the layout so it cannot widen
|
|
757
|
+
the pill it is measuring. */}
|
|
758
|
+
<View
|
|
759
|
+
pointerEvents="none"
|
|
760
|
+
style={{ position: 'absolute', opacity: 0, width: MEASURE_WIDTH }}
|
|
761
|
+
>
|
|
762
|
+
<View
|
|
763
|
+
style={{ alignSelf: 'flex-start' }}
|
|
764
|
+
onLayout={(event: LayoutChangeEvent) => {
|
|
765
|
+
/*
|
|
766
|
+
* Rounded up and given a couple of points over.
|
|
767
|
+
*
|
|
768
|
+
* The measurement and the box it is put back into are two different
|
|
769
|
+
* layout passes, and a fraction of a point between them is enough
|
|
770
|
+
* for a single-line `Text` to decide it does not fit and truncate —
|
|
771
|
+
* which shows up as a label permanently missing its last letter.
|
|
772
|
+
*/
|
|
773
|
+
const measured = Math.ceil(event.nativeEvent.layout.width) + LABEL_SLACK;
|
|
774
|
+
if (measured > LABEL_SLACK && measured !== labelWidth) {
|
|
775
|
+
setLabelWidth(measured);
|
|
776
|
+
}
|
|
777
|
+
}}
|
|
778
|
+
>
|
|
779
|
+
{label}
|
|
780
|
+
</View>
|
|
781
|
+
</View>
|
|
782
|
+
{badge}
|
|
783
|
+
</Pressable>
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
504
787
|
export interface TabsContentProps extends ViewProps {
|
|
505
788
|
className?: string;
|
|
506
789
|
value: string;
|
|
@@ -529,13 +812,25 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
|
|
|
529
812
|
count.value = tabs.length;
|
|
530
813
|
}, [position, tabs.length, index, count]);
|
|
531
814
|
|
|
815
|
+
/*
|
|
816
|
+
* Same reason as `setValue` on the root: `step` is reached through the pan
|
|
817
|
+
* gesture, and a `step` that changes identity on every switch rebuilds the
|
|
818
|
+
* gesture on every switch. The two facts it needs are read at call time
|
|
819
|
+
* instead, which is when they are wanted anyway.
|
|
820
|
+
*/
|
|
821
|
+
const tabsRef = useRef(tabs);
|
|
822
|
+
tabsRef.current = tabs;
|
|
823
|
+
const valueRef = useRef(value);
|
|
824
|
+
valueRef.current = value;
|
|
825
|
+
|
|
532
826
|
const step = useCallback(
|
|
533
|
-
(delta: number) => {
|
|
534
|
-
const
|
|
535
|
-
const
|
|
536
|
-
|
|
827
|
+
(delta: number, velocity: number) => {
|
|
828
|
+
const list = tabsRef.current;
|
|
829
|
+
const from = list.indexOf(valueRef.current);
|
|
830
|
+
const next = list[from + delta];
|
|
831
|
+
if (next) setValue(next, { velocity });
|
|
537
832
|
},
|
|
538
|
-
[
|
|
833
|
+
[setValue]
|
|
539
834
|
);
|
|
540
835
|
|
|
541
836
|
const pan = useMemo(
|
|
@@ -552,8 +847,16 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
|
|
|
552
847
|
const atEnd =
|
|
553
848
|
(travel > 0 && index.value === 0) ||
|
|
554
849
|
(travel < 0 && index.value >= count.value - 1);
|
|
555
|
-
|
|
556
|
-
|
|
850
|
+
/*
|
|
851
|
+
* One to one, except at the ends. The panel is under the finger for
|
|
852
|
+
* the whole of the drag, which is what lets the handover on release
|
|
853
|
+
* continue the movement rather than restart it — and a panel that
|
|
854
|
+
* moved a third as far as the finger never felt attached to it in
|
|
855
|
+
* the first place.
|
|
856
|
+
*/
|
|
857
|
+
offset.value = atEnd
|
|
858
|
+
? event.translationX * SWIPE_RESISTANCE_AT_END
|
|
859
|
+
: event.translationX;
|
|
557
860
|
})
|
|
558
861
|
.onEnd((event) => {
|
|
559
862
|
const travel = event.translationX * sign;
|
|
@@ -568,16 +871,38 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
|
|
|
568
871
|
// Distance and speed can disagree — a flick back the way it came
|
|
569
872
|
// reads as a cancel — so the direction comes from whichever of the
|
|
570
873
|
// two crossed its threshold, speed first.
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
874
|
+
const delta = fast ? (speed < 0 ? 1 : -1) : travel < 0 ? 1 : -1;
|
|
875
|
+
|
|
876
|
+
/*
|
|
877
|
+
* The outgoing panel carries on off the edge on the UI thread,
|
|
878
|
+
* immediately, rather than waiting to be told what happened.
|
|
879
|
+
*
|
|
880
|
+
* It used to hold wherever the finger left it until `setValue`
|
|
881
|
+
* landed — which is fine when React commits in a frame, and is a
|
|
882
|
+
* visible freeze when the arriving panel is expensive to mount, a
|
|
883
|
+
* list of any size being the usual case. The panel appeared to
|
|
884
|
+
* stick to the screen for exactly as long as the JS thread was
|
|
885
|
+
* busy, which reads as the swipe having dropped the gesture.
|
|
886
|
+
*
|
|
887
|
+
* `setValue` still places the arriving panel *relative* to this
|
|
888
|
+
* one, reading the offset live at commit time, so continuing the
|
|
889
|
+
* movement here does not desynchronise the handover: the panel
|
|
890
|
+
* that arrives is one width from wherever this one has got to,
|
|
891
|
+
* whenever that turns out to be.
|
|
892
|
+
*/
|
|
893
|
+
offset.value = withSpring(-delta * sign * width.value, {
|
|
894
|
+
...ENTER_SPRING,
|
|
895
|
+
velocity: event.velocityX,
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
runOnJS(step)(delta, event.velocityX);
|
|
576
899
|
return;
|
|
577
900
|
}
|
|
578
901
|
|
|
579
|
-
// Nothing changed hands, so this panel simply comes back
|
|
580
|
-
|
|
902
|
+
// Nothing changed hands, so this panel simply comes back — with the
|
|
903
|
+
// speed it was let go at, so a cancelled flick decelerates rather
|
|
904
|
+
// than stopping and being pulled.
|
|
905
|
+
offset.value = withSpring(0, { ...SPRING, velocity: event.velocityX });
|
|
581
906
|
})
|
|
582
907
|
.onFinalize((_event, success) => {
|
|
583
908
|
// A cancelled gesture never reaches `onEnd`, and would otherwise
|
|
@@ -587,9 +912,19 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
|
|
|
587
912
|
[sign, step, offset, width, index, count]
|
|
588
913
|
);
|
|
589
914
|
|
|
590
|
-
const followStyle = useAnimatedStyle(() =>
|
|
591
|
-
|
|
592
|
-
|
|
915
|
+
const followStyle = useAnimatedStyle(() => {
|
|
916
|
+
// No `opacity` key at all when there is no fade, rather than a constant 1.
|
|
917
|
+
// Declaring it would still mark the property as animated and hand the view
|
|
918
|
+
// an alpha to composite every frame — the cost this is avoiding.
|
|
919
|
+
if (!SWIPE_FADE) return { transform: [{ translateX: offset.value }] };
|
|
920
|
+
|
|
921
|
+
const span = width.value || 1;
|
|
922
|
+
const travelled = Math.min(1, Math.abs(offset.value) / span);
|
|
923
|
+
return {
|
|
924
|
+
transform: [{ translateX: offset.value }],
|
|
925
|
+
opacity: 1 - SWIPE_FADE * travelled,
|
|
926
|
+
};
|
|
927
|
+
});
|
|
593
928
|
|
|
594
929
|
if (!active && !context.keepMounted) return null;
|
|
595
930
|
|
|
@@ -623,7 +958,14 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
|
|
|
623
958
|
const measured = event.nativeEvent.layout.width;
|
|
624
959
|
if (measured > 0) width.value = measured;
|
|
625
960
|
}}
|
|
626
|
-
|
|
961
|
+
/*
|
|
962
|
+
* The follow style goes on the panel that is moving, and only that one.
|
|
963
|
+
* Under `keepMounted` every other panel is `display: none` and cannot be
|
|
964
|
+
* seen to move — but an animated style still subscribes them all to the
|
|
965
|
+
* offset, so a five-tab set ran five mappers per frame to reposition four
|
|
966
|
+
* views nobody was looking at.
|
|
967
|
+
*/
|
|
968
|
+
style={[!active && { display: 'none' }, swipeable && active && followStyle, style]}
|
|
627
969
|
pointerEvents={active ? 'auto' : 'none'}
|
|
628
970
|
accessibilityElementsHidden={!active}
|
|
629
971
|
importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}
|