panelui-native 0.51.1 → 0.54.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 +4 -1
- package/lib/module/components/candlestick-chart/index.js +1161 -0
- package/lib/module/components/candlestick-chart/index.js.map +1 -0
- package/lib/module/components/combobox/index.js +73 -7
- package/lib/module/components/combobox/index.js.map +1 -1
- package/lib/module/components/context-menu/index.js +529 -0
- package/lib/module/components/context-menu/index.js.map +1 -0
- package/lib/module/components/menu/index.js +18 -10
- package/lib/module/components/menu/index.js.map +1 -1
- package/lib/module/components/popover/index.js +71 -7
- package/lib/module/components/popover/index.js.map +1 -1
- package/lib/module/components/sortable/index.js +159 -23
- package/lib/module/components/sortable/index.js.map +1 -1
- package/lib/module/components/tabs/index.js +56 -15
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/components/time-picker/index.js +295 -31
- package/lib/module/components/time-picker/index.js.map +1 -1
- package/lib/module/index.js +3 -1
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/candlestick-chart/index.d.ts +278 -0
- package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/context-menu/index.d.ts +270 -0
- package/lib/typescript/src/components/context-menu/index.d.ts.map +1 -0
- package/lib/typescript/src/components/menu/index.d.ts +20 -20
- package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
- package/lib/typescript/src/components/popover/index.d.ts +51 -1
- package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
- package/lib/typescript/src/components/sortable/index.d.ts +13 -3
- package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tabs/index.d.ts +34 -2
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/candlestick-chart/index.tsx +1360 -0
- package/src/components/combobox/index.tsx +149 -18
- package/src/components/context-menu/index.tsx +658 -0
- package/src/components/menu/index.tsx +17 -10
- package/src/components/popover/index.tsx +94 -6
- package/src/components/sortable/index.tsx +278 -36
- package/src/components/tabs/index.tsx +82 -16
- package/src/components/tag-input/index.tsx +619 -0
- package/src/components/time-picker/index.tsx +330 -35
- package/src/index.ts +35 -0
|
@@ -76,6 +76,7 @@ import Animated, {
|
|
|
76
76
|
scrollTo,
|
|
77
77
|
useAnimatedRef,
|
|
78
78
|
useAnimatedStyle,
|
|
79
|
+
useDerivedValue,
|
|
79
80
|
useFrameCallback,
|
|
80
81
|
useReducedMotion,
|
|
81
82
|
useScrollViewOffset,
|
|
@@ -93,8 +94,14 @@ import { impactKnock, selectionTick } from '../../utils/haptics';
|
|
|
93
94
|
* Rows getting out of the way of the one being carried. Quick, because they
|
|
94
95
|
* are answering a finger that has already moved — a neighbour that ambles into
|
|
95
96
|
* its new slot reads as the list struggling to keep up with the drag.
|
|
97
|
+
*
|
|
98
|
+
* Critically damped, and stiff. Both were wrong before: the spring overshot its
|
|
99
|
+
* slot and spent the rest of a third of a second coming back, so a row the
|
|
100
|
+
* finger had already passed was still visibly moving. A row getting out of the
|
|
101
|
+
* way has nothing to express by bouncing — it is not the thing being carried,
|
|
102
|
+
* and the fastest way to say "your place is free" is to be out of it.
|
|
96
103
|
*/
|
|
97
|
-
const DISPLACE = { damping:
|
|
104
|
+
const DISPLACE = { damping: 28, stiffness: 400, mass: 0.5 } as const;
|
|
98
105
|
|
|
99
106
|
/**
|
|
100
107
|
* Settles a row into its slot. Stiffer and less bouncy than the library's
|
|
@@ -222,34 +229,114 @@ function slotCenter(
|
|
|
222
229
|
}
|
|
223
230
|
|
|
224
231
|
/**
|
|
225
|
-
*
|
|
232
|
+
* The order after a row has moved, with pinned rows left where they were.
|
|
233
|
+
*
|
|
234
|
+
* The move is applied first, to the whole list, so a carried row can be dragged
|
|
235
|
+
* *past* a pinned one — refusing the move instead would make a pinned row a
|
|
236
|
+
* wall, and a row that holds its place is not the same as a row nothing may
|
|
237
|
+
* cross. The pinned ids are then put back at the indices they occupy in the
|
|
238
|
+
* laid-out order, and everything else falls into the slots that are left, in
|
|
239
|
+
* the order the move produced.
|
|
240
|
+
*
|
|
241
|
+
* `laid` rather than `list` is what the fixed indices are read from, because
|
|
242
|
+
* that is the one order a pinned row is guaranteed to be correctly placed in:
|
|
243
|
+
* it is where it was rendered, and holding its slot is the whole point.
|
|
244
|
+
*/
|
|
245
|
+
function moveWithPinned(
|
|
246
|
+
list: readonly string[],
|
|
247
|
+
laid: readonly string[],
|
|
248
|
+
pinned: Record<string, boolean>,
|
|
249
|
+
id: string,
|
|
250
|
+
from: number,
|
|
251
|
+
to: number
|
|
252
|
+
): string[] {
|
|
253
|
+
'worklet';
|
|
254
|
+
const moved = [...list];
|
|
255
|
+
moved.splice(from, 1);
|
|
256
|
+
moved.splice(to, 0, id);
|
|
257
|
+
|
|
258
|
+
const next: (string | undefined)[] = [];
|
|
259
|
+
let anyPinned = false;
|
|
260
|
+
for (let i = 0; i < moved.length; i += 1) next.push(undefined);
|
|
261
|
+
for (let i = 0; i < laid.length && i < next.length; i += 1) {
|
|
262
|
+
const at = laid[i];
|
|
263
|
+
if (at !== undefined && pinned[at]) {
|
|
264
|
+
next[i] = at;
|
|
265
|
+
anyPinned = true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!anyPinned) return moved;
|
|
269
|
+
|
|
270
|
+
const free: string[] = [];
|
|
271
|
+
for (let i = 0; i < moved.length; i += 1) {
|
|
272
|
+
const at = moved[i];
|
|
273
|
+
if (at !== undefined && !pinned[at]) free.push(at);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const result: string[] = [];
|
|
277
|
+
let f = 0;
|
|
278
|
+
for (let i = 0; i < next.length; i += 1) {
|
|
279
|
+
const held = next[i];
|
|
280
|
+
if (held !== undefined) {
|
|
281
|
+
result.push(held);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const take = free[f];
|
|
285
|
+
f += 1;
|
|
286
|
+
if (take !== undefined) result.push(take);
|
|
287
|
+
}
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Where the dragged row belongs now, given where its edges have reached.
|
|
226
293
|
*
|
|
227
294
|
* It walks outwards from the row's current slot and stops at the first
|
|
228
|
-
* neighbour it has
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
295
|
+
* neighbour it has not reached, rather than scanning the whole list for the
|
|
296
|
+
* nearest slot. The difference shows up with rows of unequal height: scanning
|
|
297
|
+
* can hand back a slot two places away that happens to be closer, which reads
|
|
298
|
+
* as the row skipping one.
|
|
299
|
+
*
|
|
300
|
+
* What counts as reaching a neighbour is the *leading edge* of the carried row
|
|
301
|
+
* against that neighbour's middle — its bottom edge going down, its top edge
|
|
302
|
+
* going up. Comparing middle against middle, as this used to, means the finger
|
|
303
|
+
* has to travel a whole row before anything happens, because a row's middle
|
|
304
|
+
* starts a whole row away from its neighbour's: the list sat still through the
|
|
305
|
+
* first row of every drag and then moved all at once. Leading edge against
|
|
306
|
+
* middle halves that, and it is also the more natural reading — the rows get
|
|
307
|
+
* out of the way once the row being carried is over them, not once it is past
|
|
308
|
+
* them.
|
|
232
309
|
*/
|
|
233
310
|
function targetIndex(
|
|
234
311
|
order: readonly string[],
|
|
235
312
|
current: number,
|
|
236
|
-
|
|
313
|
+
top: number,
|
|
314
|
+
height: number,
|
|
237
315
|
heights: Record<string, number>,
|
|
238
316
|
gap: number
|
|
239
317
|
): number {
|
|
240
318
|
'worklet';
|
|
241
|
-
|
|
319
|
+
// Where the carried row's own slot begins, so the direction of travel is
|
|
320
|
+
// read from the row rather than from the sign of a gesture that may have
|
|
321
|
+
// changed its mind since.
|
|
322
|
+
const self = order[current];
|
|
323
|
+
const restingTop =
|
|
324
|
+
slotCenter(order, current, heights, gap) -
|
|
325
|
+
(self === undefined ? 0 : (heights[self] ?? 0)) / 2;
|
|
326
|
+
|
|
327
|
+
if (top < restingTop) {
|
|
242
328
|
let target = current;
|
|
243
329
|
for (let i = current - 1; i >= 0; i -= 1) {
|
|
244
|
-
if (
|
|
330
|
+
if (top >= slotCenter(order, i, heights, gap)) break;
|
|
245
331
|
target = i;
|
|
246
332
|
}
|
|
247
333
|
return target;
|
|
248
334
|
}
|
|
249
335
|
|
|
336
|
+
const bottom = top + height;
|
|
250
337
|
let target = current;
|
|
251
338
|
for (let i = current + 1; i < order.length; i += 1) {
|
|
252
|
-
if (
|
|
339
|
+
if (bottom <= slotCenter(order, i, heights, gap)) break;
|
|
253
340
|
target = i;
|
|
254
341
|
}
|
|
255
342
|
return target;
|
|
@@ -266,6 +353,20 @@ interface SortableContextValue {
|
|
|
266
353
|
rendered: SharedValue<string[]>;
|
|
267
354
|
/** Measured row heights, keyed by id. */
|
|
268
355
|
heights: SharedValue<Record<string, number>>;
|
|
356
|
+
/** Which ids hold their slot. Read on the UI thread while a drag resolves. */
|
|
357
|
+
pinned: SharedValue<Record<string, boolean>>;
|
|
358
|
+
/**
|
|
359
|
+
* How far each row is from where it was laid out, keyed by id.
|
|
360
|
+
*
|
|
361
|
+
* Derived once per rearrangement rather than worked out by each row for
|
|
362
|
+
* itself. Every row's style worklet re-runs on every frame of a drag — it
|
|
363
|
+
* closes over the value the carried row is riding on — so a row summing the
|
|
364
|
+
* heights above it twice per frame made the list cost the square of its
|
|
365
|
+
* length to drag, which is felt exactly when a list is long enough to be
|
|
366
|
+
* worth reordering by hand. This is invalidated by the same shared values it
|
|
367
|
+
* is built from, so a row changing height still puts it right.
|
|
368
|
+
*/
|
|
369
|
+
offsets: SharedValue<Record<string, number>>;
|
|
269
370
|
/** The row under the finger, or `null`. Also the settling row, until it lands. */
|
|
270
371
|
activeId: SharedValue<string | null>;
|
|
271
372
|
/** The active row's offset from where it was laid out. */
|
|
@@ -300,6 +401,8 @@ interface SortableContextValue {
|
|
|
300
401
|
/** Index of each id in the rendered order. */
|
|
301
402
|
indexOf: (id: string) => number;
|
|
302
403
|
measured: (id: string, height: number) => void;
|
|
404
|
+
/** Register or clear a row's hold on its slot. */
|
|
405
|
+
setPinned: (id: string, value: boolean) => void;
|
|
303
406
|
begin: (id: string) => void;
|
|
304
407
|
settled: (id: string) => void;
|
|
305
408
|
/** Move a row by whole slots — the path that is not a gesture. */
|
|
@@ -437,6 +540,7 @@ function SortableRoot({
|
|
|
437
540
|
const order = useSharedValue<string[]>(value);
|
|
438
541
|
const rendered = useSharedValue<string[]>(value);
|
|
439
542
|
const heights = useSharedValue<Record<string, number>>({});
|
|
543
|
+
const pinned = useSharedValue<Record<string, boolean>>({});
|
|
440
544
|
const activeId = useSharedValue<string | null>(null);
|
|
441
545
|
const translate = useSharedValue(0);
|
|
442
546
|
const lift = useSharedValue(0);
|
|
@@ -479,14 +583,69 @@ function SortableRoot({
|
|
|
479
583
|
|
|
480
584
|
const indexOf = useCallback((id: string) => indices.get(id) ?? -1, [indices]);
|
|
481
585
|
|
|
586
|
+
/*
|
|
587
|
+
* Both maps are accumulated in a ref and then published, rather than built by
|
|
588
|
+
* reading the shared value back and spreading it. Every row reports its
|
|
589
|
+
* layout in the same batch on mount, and a write to `.value` is not visible
|
|
590
|
+
* to the next read in that batch — so a read-modify-write there has all the
|
|
591
|
+
* rows spreading the same empty map and only the last one surviving. A list
|
|
592
|
+
* that knows one row's height puts every slot a gap apart, and the first
|
|
593
|
+
* drag drops the row at the end of the list.
|
|
594
|
+
*/
|
|
595
|
+
const measuredHeights = useRef<Record<string, number>>({});
|
|
596
|
+
const pinnedFlags = useRef<Record<string, boolean>>({});
|
|
597
|
+
|
|
482
598
|
const measured = useCallback(
|
|
483
599
|
(id: string, height: number) => {
|
|
484
|
-
if (
|
|
485
|
-
|
|
600
|
+
if (measuredHeights.current[id] === height) return;
|
|
601
|
+
measuredHeights.current = { ...measuredHeights.current, [id]: height };
|
|
602
|
+
heights.value = measuredHeights.current;
|
|
486
603
|
},
|
|
487
604
|
[heights]
|
|
488
605
|
);
|
|
489
606
|
|
|
607
|
+
const setPinned = useCallback(
|
|
608
|
+
(id: string, next: boolean) => {
|
|
609
|
+
if (Boolean(pinnedFlags.current[id]) === next) return;
|
|
610
|
+
pinnedFlags.current = { ...pinnedFlags.current, [id]: next };
|
|
611
|
+
pinned.value = pinnedFlags.current;
|
|
612
|
+
},
|
|
613
|
+
[pinned]
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
/*
|
|
617
|
+
* Every row's distance from where it was laid out, in one pass.
|
|
618
|
+
*
|
|
619
|
+
* Two prefix sums — one over the order being dragged, one over the order the
|
|
620
|
+
* children are actually in — and the difference between them per id. Rebuilt
|
|
621
|
+
* when a swap changes `order`, when a drop resets both, or when a row reports
|
|
622
|
+
* a new height, and at no other time; a drag that is only moving the carried
|
|
623
|
+
* row does not touch it at all.
|
|
624
|
+
*/
|
|
625
|
+
const offsets = useDerivedValue<Record<string, number>>(() => {
|
|
626
|
+
const map = heights.value;
|
|
627
|
+
const target: Record<string, number> = {};
|
|
628
|
+
const list = order.value;
|
|
629
|
+
let at = 0;
|
|
630
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
631
|
+
const id = list[i];
|
|
632
|
+
if (id === undefined) continue;
|
|
633
|
+
target[id] = at;
|
|
634
|
+
at += (map[id] ?? 0) + gap;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const result: Record<string, number> = {};
|
|
638
|
+
const laid = rendered.value;
|
|
639
|
+
at = 0;
|
|
640
|
+
for (let i = 0; i < laid.length; i += 1) {
|
|
641
|
+
const id = laid[i];
|
|
642
|
+
if (id === undefined) continue;
|
|
643
|
+
result[id] = (target[id] ?? at) - at;
|
|
644
|
+
at += (map[id] ?? 0) + gap;
|
|
645
|
+
}
|
|
646
|
+
return result;
|
|
647
|
+
}, [gap]);
|
|
648
|
+
|
|
490
649
|
/* ---------------------------------------------------------------------- */
|
|
491
650
|
/* Autoscroll */
|
|
492
651
|
/* ---------------------------------------------------------------------- */
|
|
@@ -607,6 +766,8 @@ function SortableRoot({
|
|
|
607
766
|
order,
|
|
608
767
|
rendered,
|
|
609
768
|
heights,
|
|
769
|
+
pinned,
|
|
770
|
+
offsets,
|
|
610
771
|
activeId,
|
|
611
772
|
translate,
|
|
612
773
|
lift,
|
|
@@ -623,6 +784,7 @@ function SortableRoot({
|
|
|
623
784
|
activeItem,
|
|
624
785
|
indexOf,
|
|
625
786
|
measured,
|
|
787
|
+
setPinned,
|
|
626
788
|
begin,
|
|
627
789
|
settled,
|
|
628
790
|
step,
|
|
@@ -632,6 +794,8 @@ function SortableRoot({
|
|
|
632
794
|
order,
|
|
633
795
|
rendered,
|
|
634
796
|
heights,
|
|
797
|
+
pinned,
|
|
798
|
+
offsets,
|
|
635
799
|
activeId,
|
|
636
800
|
translate,
|
|
637
801
|
lift,
|
|
@@ -648,6 +812,7 @@ function SortableRoot({
|
|
|
648
812
|
activeItem,
|
|
649
813
|
indexOf,
|
|
650
814
|
measured,
|
|
815
|
+
setPinned,
|
|
651
816
|
begin,
|
|
652
817
|
settled,
|
|
653
818
|
step,
|
|
@@ -686,10 +851,20 @@ export interface SortableItemProps extends Omit<ViewProps, 'children'> {
|
|
|
686
851
|
/**
|
|
687
852
|
* Stop this row being picked up. The others still move past it, because a
|
|
688
853
|
* row that cannot be dragged is not the same as a row that cannot be
|
|
689
|
-
* displaced —
|
|
690
|
-
*
|
|
854
|
+
* displaced — that is what `pinned` is for, and conflating the two would mean
|
|
855
|
+
* silently refusing drops that look like they worked.
|
|
691
856
|
*/
|
|
692
857
|
disabled?: boolean;
|
|
858
|
+
/**
|
|
859
|
+
* Hold this row's place in the list. It cannot be picked up, and — unlike a
|
|
860
|
+
* `disabled` row — nothing else can take its slot either: the rows being
|
|
861
|
+
* dragged reorder among the places left over, and one carried past this row
|
|
862
|
+
* goes around it rather than through it.
|
|
863
|
+
*
|
|
864
|
+
* For the row that means something by being where it is. A header, a total, a
|
|
865
|
+
* step that has to come first.
|
|
866
|
+
*/
|
|
867
|
+
pinned?: boolean;
|
|
693
868
|
/**
|
|
694
869
|
* Extra classes for the row while it is being carried, applied last. A
|
|
695
870
|
* lifted row is given an opaque surface and a shadow so it is never drawn
|
|
@@ -711,6 +886,7 @@ function SortableItem({
|
|
|
711
886
|
id,
|
|
712
887
|
children,
|
|
713
888
|
disabled = false,
|
|
889
|
+
pinned = false,
|
|
714
890
|
...props
|
|
715
891
|
}: SortableItemProps) {
|
|
716
892
|
const root = useSortableRoot('Sortable.Item');
|
|
@@ -718,6 +894,8 @@ function SortableItem({
|
|
|
718
894
|
order,
|
|
719
895
|
rendered,
|
|
720
896
|
heights,
|
|
897
|
+
pinned: pinnedIds,
|
|
898
|
+
offsets,
|
|
721
899
|
activeId,
|
|
722
900
|
translate,
|
|
723
901
|
lift,
|
|
@@ -733,13 +911,23 @@ function SortableItem({
|
|
|
733
911
|
activeItem,
|
|
734
912
|
indexOf,
|
|
735
913
|
measured,
|
|
914
|
+
setPinned,
|
|
736
915
|
begin,
|
|
737
916
|
settled,
|
|
738
917
|
step,
|
|
739
918
|
} = root;
|
|
740
919
|
|
|
741
|
-
/** A row is undraggable if
|
|
742
|
-
const locked = root.disabled || disabled;
|
|
920
|
+
/** A row is undraggable if it, the whole list, or its own pin says so. */
|
|
921
|
+
const locked = root.disabled || disabled || pinned;
|
|
922
|
+
|
|
923
|
+
/*
|
|
924
|
+
* Published to the root so the drag can read it on the UI thread. A pin is
|
|
925
|
+
* resolved while a finger is moving, where the props of a row two places away
|
|
926
|
+
* are not reachable.
|
|
927
|
+
*/
|
|
928
|
+
useEffect(() => {
|
|
929
|
+
setPinned(id, pinned);
|
|
930
|
+
}, [id, pinned, setPinned]);
|
|
743
931
|
const index = indexOf(id);
|
|
744
932
|
const isActive = activeItem === id;
|
|
745
933
|
|
|
@@ -811,13 +999,20 @@ function SortableItem({
|
|
|
811
999
|
if (current < 0) return;
|
|
812
1000
|
|
|
813
1001
|
const top = slotOffset(rendered.value, id, map, gap) + translate.value;
|
|
814
|
-
const
|
|
815
|
-
const to = targetIndex(list, current, center, map, gap);
|
|
1002
|
+
const to = targetIndex(list, current, top, map[id] ?? 0, map, gap);
|
|
816
1003
|
if (to === current) return;
|
|
817
1004
|
|
|
818
|
-
const next =
|
|
819
|
-
|
|
820
|
-
|
|
1005
|
+
const next = moveWithPinned(
|
|
1006
|
+
list,
|
|
1007
|
+
rendered.value,
|
|
1008
|
+
pinnedIds.value,
|
|
1009
|
+
id,
|
|
1010
|
+
current,
|
|
1011
|
+
to
|
|
1012
|
+
);
|
|
1013
|
+
// A move that only pinned rows could have absorbed leaves the order
|
|
1014
|
+
// exactly as it was, and there is nothing to feel or to redraw.
|
|
1015
|
+
if (next[current] === id) return;
|
|
821
1016
|
order.value = next;
|
|
822
1017
|
|
|
823
1018
|
if (haptics) runOnJS(notifyTick)();
|
|
@@ -850,10 +1045,15 @@ function SortableItem({
|
|
|
850
1045
|
* drop unreported; `seq` is what tells the two cases apart, because
|
|
851
1046
|
* the only interruption that should be ignored is the row being picked
|
|
852
1047
|
* up again.
|
|
1048
|
+
*
|
|
1049
|
+
* `activeId` is checked as well as `seq` because reporting the drop is
|
|
1050
|
+
* itself what interrupts the spring: the caller applies the reorder,
|
|
1051
|
+
* and the reset that follows puts `translate` back to rest, which ends
|
|
1052
|
+
* the animation and calls this a second time under the same `seq`.
|
|
853
1053
|
*/
|
|
854
1054
|
const land = () => {
|
|
855
1055
|
'worklet';
|
|
856
|
-
if (dragSeq.value !== seq) return;
|
|
1056
|
+
if (dragSeq.value !== seq || activeId.value === null) return;
|
|
857
1057
|
activeId.value = null;
|
|
858
1058
|
runOnJS(notifySettled)(id);
|
|
859
1059
|
};
|
|
@@ -914,9 +1114,14 @@ function SortableItem({
|
|
|
914
1114
|
};
|
|
915
1115
|
}
|
|
916
1116
|
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1117
|
+
/*
|
|
1118
|
+
* Read, not worked out. This worklet re-runs on every frame of a drag —
|
|
1119
|
+
* it closes over the value the carried row rides on — so summing the
|
|
1120
|
+
* heights above this row here, twice, made a list cost the square of its
|
|
1121
|
+
* length to drag. The root derives every row's offset in one pass instead,
|
|
1122
|
+
* and only when the arrangement actually changes.
|
|
1123
|
+
*/
|
|
1124
|
+
const offset = offsets.value[id] ?? 0;
|
|
920
1125
|
|
|
921
1126
|
/*
|
|
922
1127
|
* Only animated while a drag is in flight, and the difference is the whole
|
|
@@ -956,17 +1161,27 @@ function SortableItem({
|
|
|
956
1161
|
* no way to discover it from the row. Moving by whole slots is published as
|
|
957
1162
|
* an accessibility action instead, which is the only path to reordering for
|
|
958
1163
|
* someone who is not dragging anything.
|
|
1164
|
+
*
|
|
1165
|
+
* The actions sit wherever the drag does. A handle list keeps them on the
|
|
1166
|
+
* grip, which is an element in its own right; put here they would never be
|
|
1167
|
+
* offered, because the actions of a view that is not itself an accessibility
|
|
1168
|
+
* element are not reachable, and a row full of text is not one. A long-press
|
|
1169
|
+
* list has no grip and gives the whole row to the drag, so the row becomes
|
|
1170
|
+
* the element — which is what a screen reader wants from a row in any case.
|
|
959
1171
|
*/
|
|
960
|
-
const
|
|
961
|
-
|
|
962
|
-
|
|
1172
|
+
const carriesActions = activation === 'longPress' && !locked;
|
|
1173
|
+
|
|
1174
|
+
const a11y = carriesActions
|
|
1175
|
+
? [
|
|
963
1176
|
{ name: 'moveUp', label: 'Move up' },
|
|
964
1177
|
{ name: 'moveDown', label: 'Move down' },
|
|
965
|
-
]
|
|
1178
|
+
]
|
|
1179
|
+
: undefined;
|
|
966
1180
|
|
|
967
1181
|
const row = (
|
|
968
1182
|
<Animated.View
|
|
969
1183
|
onLayout={onLayout}
|
|
1184
|
+
accessible={carriesActions}
|
|
970
1185
|
accessibilityActions={a11y}
|
|
971
1186
|
onAccessibilityAction={(event) => {
|
|
972
1187
|
if (event.nativeEvent.actionName === 'moveUp') step(id, -1);
|
|
@@ -1038,7 +1253,7 @@ function SortableHandle({
|
|
|
1038
1253
|
accessibilityLabel = 'Drag to reorder',
|
|
1039
1254
|
...props
|
|
1040
1255
|
}: SortableHandleProps) {
|
|
1041
|
-
const { activation, disabled: rootDisabled } = useSortableRoot('Sortable.Handle');
|
|
1256
|
+
const { activation, disabled: rootDisabled, step } = useSortableRoot('Sortable.Handle');
|
|
1042
1257
|
const item = useContext(SortableItemContext);
|
|
1043
1258
|
|
|
1044
1259
|
/*
|
|
@@ -1049,17 +1264,44 @@ function SortableHandle({
|
|
|
1049
1264
|
const muted = useCSSVariable('--color-muted-foreground');
|
|
1050
1265
|
const tint = typeof muted === 'string' ? muted : undefined;
|
|
1051
1266
|
|
|
1267
|
+
const locked = rootDisabled || item?.disabled;
|
|
1268
|
+
|
|
1269
|
+
/*
|
|
1270
|
+
* `adjustable` promises an element that answers a swipe up or down, and the
|
|
1271
|
+
* promise was never kept: the grip published the role and nothing else, so
|
|
1272
|
+
* the one part of a row a screen reader could reach did nothing at all.
|
|
1273
|
+
* Moving by whole slots is what it was always meant to do. The same move is
|
|
1274
|
+
* offered as a named action too, because a swipe says nothing about which
|
|
1275
|
+
* way the row is going to travel.
|
|
1276
|
+
*/
|
|
1277
|
+
const move = (delta: number) => {
|
|
1278
|
+
if (locked || !item) return;
|
|
1279
|
+
step(item.id, delta);
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1052
1282
|
const glyph = (
|
|
1053
1283
|
<View
|
|
1054
1284
|
accessible
|
|
1055
1285
|
accessibilityRole="adjustable"
|
|
1056
1286
|
accessibilityLabel={accessibilityLabel}
|
|
1057
|
-
accessibilityState={{ disabled:
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1287
|
+
accessibilityState={{ disabled: locked }}
|
|
1288
|
+
accessibilityValue={item ? { text: `Position ${item.index + 1}` } : undefined}
|
|
1289
|
+
accessibilityActions={
|
|
1290
|
+
locked
|
|
1291
|
+
? undefined
|
|
1292
|
+
: [
|
|
1293
|
+
{ name: 'increment' },
|
|
1294
|
+
{ name: 'decrement' },
|
|
1295
|
+
{ name: 'moveUp', label: 'Move up' },
|
|
1296
|
+
{ name: 'moveDown', label: 'Move down' },
|
|
1297
|
+
]
|
|
1298
|
+
}
|
|
1299
|
+
onAccessibilityAction={(event) => {
|
|
1300
|
+
const action = event.nativeEvent.actionName;
|
|
1301
|
+
if (action === 'increment' || action === 'moveUp') move(-1);
|
|
1302
|
+
if (action === 'decrement' || action === 'moveDown') move(1);
|
|
1303
|
+
}}
|
|
1304
|
+
className={cn('items-center justify-center px-2 py-1.5', locked && 'opacity-40', className)}
|
|
1063
1305
|
{...props}
|
|
1064
1306
|
>
|
|
1065
1307
|
<IconColorProvider color={tint}>
|
|
@@ -140,6 +140,16 @@ const MEASURE_WIDTH = 400;
|
|
|
140
140
|
|
|
141
141
|
export type TabsVariant = 'segmented' | 'underline' | 'pill' | 'expanding';
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* How much of an inactive panel survives a switch away from it.
|
|
145
|
+
*
|
|
146
|
+
* `false` unmounts it. `true` keeps it mounted but takes it out of layout, so
|
|
147
|
+
* it costs nothing to have around. `'measured'` keeps it laid out at full size
|
|
148
|
+
* as well — the expensive option, and the only one a child that sizes itself
|
|
149
|
+
* from its parent can be built inside while it is hidden.
|
|
150
|
+
*/
|
|
151
|
+
export type TabsKeepMounted = boolean | 'measured';
|
|
152
|
+
|
|
143
153
|
const tabsVariants = tv({
|
|
144
154
|
slots: {
|
|
145
155
|
list: 'flex-row',
|
|
@@ -246,7 +256,7 @@ interface TabsContextValue {
|
|
|
246
256
|
variant: TabsVariant;
|
|
247
257
|
scrollable: boolean;
|
|
248
258
|
setScrollable: (scrollable: boolean) => void;
|
|
249
|
-
keepMounted:
|
|
259
|
+
keepMounted: TabsKeepMounted;
|
|
250
260
|
swipeable: boolean;
|
|
251
261
|
/**
|
|
252
262
|
* How far the visible panel is displaced from its resting place, in points.
|
|
@@ -292,8 +302,29 @@ export interface TabsProps extends ViewProps {
|
|
|
292
302
|
* Keep inactive panels mounted and hidden instead of unmounting them, so a
|
|
293
303
|
* scroll position or a half-filled form survives a switch away and back.
|
|
294
304
|
* Costs the render of every panel up front.
|
|
305
|
+
*
|
|
306
|
+
* `true` hides a kept panel with `display: none`, which also takes it out of
|
|
307
|
+
* layout: it is mounted, but it has no size. That is what makes it cheap, and
|
|
308
|
+
* it is enough for a panel whose content sizes itself — a column of views, a
|
|
309
|
+
* form, a `ScrollView` of known children.
|
|
310
|
+
*
|
|
311
|
+
* It is *not* enough for a child that decides what to render by measuring the
|
|
312
|
+
* space it has been given. A virtualised list asks its parent how tall it is
|
|
313
|
+
* and fills that many rows; asked inside a panel of zero height it answers
|
|
314
|
+
* zero rows, and the whole first render still lands on the frame the tab
|
|
315
|
+
* becomes visible — the stall this flag looks like it should have avoided.
|
|
316
|
+
*
|
|
317
|
+
* `'measured'` is for that case. A kept panel stays laid out at the full size
|
|
318
|
+
* of the tab set, and is hidden by not being drawn rather than by being
|
|
319
|
+
* removed from layout: a list inside it measures, renders its rows and
|
|
320
|
+
* settles while it is still hidden, so becoming visible costs nothing.
|
|
321
|
+
*
|
|
322
|
+
* The trade is real and is why it is not the default — every kept panel lays
|
|
323
|
+
* out and draws, up front and on every size change, so a five-tab set builds
|
|
324
|
+
* five panels' worth of rows to show one. Reach for it when a panel is slow
|
|
325
|
+
* to appear and its content is virtualised; leave it at `true` otherwise.
|
|
295
326
|
*/
|
|
296
|
-
keepMounted?:
|
|
327
|
+
keepMounted?: TabsKeepMounted;
|
|
297
328
|
/**
|
|
298
329
|
* Move between tabs by dragging sideways on the panel, as well as by
|
|
299
330
|
* pressing the triggers.
|
|
@@ -310,7 +341,9 @@ export interface TabsProps extends ViewProps {
|
|
|
310
341
|
* something is moving*, so a panel that is slow to build stops being a pause
|
|
311
342
|
* before it appears and starts being a stutter in the movement. If a swipe
|
|
312
343
|
* feels heavier than a press on the same tab set, the panel is expensive to
|
|
313
|
-
* mount
|
|
344
|
+
* mount — and the answer is whichever `keepMounted` actually keeps its
|
|
345
|
+
* content built, which for a virtualised list is `'measured'` rather than
|
|
346
|
+
* `true`. Turning this off hides the cost rather than removing it.
|
|
314
347
|
*/
|
|
315
348
|
swipeable?: boolean;
|
|
316
349
|
children: ReactNode;
|
|
@@ -928,11 +961,41 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
|
|
|
928
961
|
|
|
929
962
|
if (!active && !context.keepMounted) return null;
|
|
930
963
|
|
|
964
|
+
/*
|
|
965
|
+
* Under `keepMounted='measured'` a hidden panel keeps its size instead of
|
|
966
|
+
* losing it. It is taken out of the flow and stretched over the tab set, so
|
|
967
|
+
* it is laid out at the same size as the visible panel without contributing
|
|
968
|
+
* its height to the parent, and it is hidden by being transparent rather
|
|
969
|
+
* than by `display: none`.
|
|
970
|
+
*
|
|
971
|
+
* That distinction is the whole point: `display: none` lays a panel out at
|
|
972
|
+
* zero size, and a child that sizes itself from its parent — a virtualised
|
|
973
|
+
* list deciding how many rows to render — renders nothing at all inside one.
|
|
974
|
+
* Given a real size while still hidden, it builds its rows now rather than on
|
|
975
|
+
* the frame the tab is switched to.
|
|
976
|
+
*
|
|
977
|
+
* `opacity: 0` is set once and never animated, so it costs a composite and
|
|
978
|
+
* not a per-frame redraw; the negative `zIndex` keeps it behind the visible
|
|
979
|
+
* panel rather than over it, whatever order the panels are written in.
|
|
980
|
+
*/
|
|
981
|
+
const measured = context.keepMounted === 'measured';
|
|
982
|
+
const hiddenStyle = measured
|
|
983
|
+
? ({
|
|
984
|
+
position: 'absolute',
|
|
985
|
+
top: 0,
|
|
986
|
+
left: 0,
|
|
987
|
+
right: 0,
|
|
988
|
+
bottom: 0,
|
|
989
|
+
opacity: 0,
|
|
990
|
+
zIndex: -1,
|
|
991
|
+
} as const)
|
|
992
|
+
: ({ display: 'none' } as const);
|
|
993
|
+
|
|
931
994
|
/*
|
|
932
995
|
* Hidden rather than unmounted under `keepMounted`, and hidden thoroughly:
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
*
|
|
996
|
+
* it is not drawn, it takes no touches, and the accessibility props take it
|
|
997
|
+
* out of the reading order too. A screen reader walking through three panels
|
|
998
|
+
* of a tab set it cannot see is worse than no tabs at all.
|
|
936
999
|
*/
|
|
937
1000
|
const panel = (
|
|
938
1001
|
<Animated.View
|
|
@@ -953,19 +1016,21 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
|
|
|
953
1016
|
// window would be most of the way across it.
|
|
954
1017
|
//
|
|
955
1018
|
// Zero is ignored. Every panel reports into the same value, and a
|
|
956
|
-
// kept
|
|
957
|
-
//
|
|
1019
|
+
// panel kept with `display: none` measures as nothing — letting it say
|
|
1020
|
+
// so would leave the visible panel with no width. A `'measured'` one
|
|
1021
|
+
// is stretched over the tab set and reports the same width as the
|
|
1022
|
+
// visible panel, so it is agreeing rather than overwriting.
|
|
958
1023
|
const measured = event.nativeEvent.layout.width;
|
|
959
1024
|
if (measured > 0) width.value = measured;
|
|
960
1025
|
}}
|
|
961
1026
|
/*
|
|
962
1027
|
* The follow style goes on the panel that is moving, and only that one.
|
|
963
|
-
* Under `keepMounted` every other panel is
|
|
964
|
-
*
|
|
965
|
-
*
|
|
966
|
-
*
|
|
1028
|
+
* Under `keepMounted` every other panel is hidden and cannot be seen to
|
|
1029
|
+
* move — but an animated style still subscribes them all to the offset,
|
|
1030
|
+
* so a five-tab set ran five mappers per frame to reposition four views
|
|
1031
|
+
* nobody was looking at.
|
|
967
1032
|
*/
|
|
968
|
-
style={[!active &&
|
|
1033
|
+
style={[!active && hiddenStyle, swipeable && active && followStyle, style]}
|
|
969
1034
|
pointerEvents={active ? 'auto' : 'none'}
|
|
970
1035
|
accessibilityElementsHidden={!active}
|
|
971
1036
|
importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}
|
|
@@ -976,9 +1041,10 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
|
|
|
976
1041
|
</Animated.View>
|
|
977
1042
|
);
|
|
978
1043
|
|
|
979
|
-
// Only the visible panel carries the gesture. A kept-mounted panel
|
|
980
|
-
//
|
|
981
|
-
// each of them would put several competing
|
|
1044
|
+
// Only the visible panel carries the gesture. A kept-mounted panel takes no
|
|
1045
|
+
// touches anyway — `pointerEvents` is off on it whichever way it is hidden —
|
|
1046
|
+
// but attaching a detector to each of them would put several competing
|
|
1047
|
+
// recognisers in the same tree.
|
|
982
1048
|
if (!swipeable || !active) return panel;
|
|
983
1049
|
|
|
984
1050
|
return <GestureDetector gesture={pan}>{panel}</GestureDetector>;
|