panelui-native 0.51.1 → 0.53.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 (45) hide show
  1. package/README.md +2 -1
  2. package/lib/module/components/candlestick-chart/index.js +1161 -0
  3. package/lib/module/components/candlestick-chart/index.js.map +1 -0
  4. package/lib/module/components/combobox/index.js +73 -7
  5. package/lib/module/components/combobox/index.js.map +1 -1
  6. package/lib/module/components/context-menu/index.js +529 -0
  7. package/lib/module/components/context-menu/index.js.map +1 -0
  8. package/lib/module/components/menu/index.js +18 -10
  9. package/lib/module/components/menu/index.js.map +1 -1
  10. package/lib/module/components/popover/index.js +71 -7
  11. package/lib/module/components/popover/index.js.map +1 -1
  12. package/lib/module/components/sortable/index.js +159 -23
  13. package/lib/module/components/sortable/index.js.map +1 -1
  14. package/lib/module/components/tabs/index.js +56 -15
  15. package/lib/module/components/tabs/index.js.map +1 -1
  16. package/lib/module/components/time-picker/index.js +295 -31
  17. package/lib/module/components/time-picker/index.js.map +1 -1
  18. package/lib/module/index.js +3 -1
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/typescript/src/components/candlestick-chart/index.d.ts +278 -0
  21. package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -0
  22. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  23. package/lib/typescript/src/components/context-menu/index.d.ts +270 -0
  24. package/lib/typescript/src/components/context-menu/index.d.ts.map +1 -0
  25. package/lib/typescript/src/components/menu/index.d.ts +20 -20
  26. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  27. package/lib/typescript/src/components/popover/index.d.ts +51 -1
  28. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/sortable/index.d.ts +13 -3
  30. package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/tabs/index.d.ts +34 -2
  32. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  34. package/lib/typescript/src/index.d.ts +3 -1
  35. package/lib/typescript/src/index.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/components/candlestick-chart/index.tsx +1360 -0
  38. package/src/components/combobox/index.tsx +84 -6
  39. package/src/components/context-menu/index.tsx +658 -0
  40. package/src/components/menu/index.tsx +17 -10
  41. package/src/components/popover/index.tsx +94 -6
  42. package/src/components/sortable/index.tsx +208 -22
  43. package/src/components/tabs/index.tsx +82 -16
  44. package/src/components/time-picker/index.tsx +330 -35
  45. package/src/index.ts +27 -0
@@ -341,7 +341,7 @@ export interface MenuBackgroundProps extends ViewProps {
341
341
  * `overflow-hidden` on the panel is what keeps whatever that is inside the
342
342
  * corner radius.
343
343
  */
344
- function MenuBackground({ className, children, ...props }: MenuBackgroundProps) {
344
+ export function MenuBackground({ className, children, ...props }: MenuBackgroundProps) {
345
345
  const { background } = menuVariants();
346
346
 
347
347
  return (
@@ -365,7 +365,7 @@ export interface MenuLabelProps extends ViewProps {
365
365
  }
366
366
 
367
367
  /** Non-interactive heading over a run of rows. */
368
- function MenuLabel({ className, inset, children, ...props }: MenuLabelProps) {
368
+ export function MenuLabel({ className, inset, children, ...props }: MenuLabelProps) {
369
369
  const { label } = menuVariants({ inset });
370
370
 
371
371
  return (
@@ -384,7 +384,7 @@ export interface MenuSeparatorProps extends ViewProps {
384
384
  }
385
385
 
386
386
  /** Hairline between two runs of rows. */
387
- function MenuSeparator({ className, ...props }: MenuSeparatorProps) {
387
+ export function MenuSeparator({ className, ...props }: MenuSeparatorProps) {
388
388
  const { separator } = menuVariants();
389
389
 
390
390
  return (
@@ -435,7 +435,7 @@ export interface MenuItemProps extends Omit<PressableProps, 'children' | 'style'
435
435
  * that a row which is disabled or which opts out via `closeOnSelect` behaves
436
436
  * the same either way.
437
437
  */
438
- function MenuItem({
438
+ export function MenuItem({
439
439
  className,
440
440
  children,
441
441
  icon,
@@ -540,7 +540,7 @@ export interface MenuCheckboxItemProps extends Omit<MenuItemProps, 'icon' | 'ins
540
540
  * deliberately so: a set of toggles is nearly always set more than one at a
541
541
  * time, and closing after each one turns three taps into six.
542
542
  */
543
- function MenuCheckboxItem({
543
+ export function MenuCheckboxItem({
544
544
  checked = false,
545
545
  onCheckedChange,
546
546
  onSelect,
@@ -592,7 +592,7 @@ export interface MenuRadioGroupProps extends ViewProps {
592
592
  }
593
593
 
594
594
  /** A run of rows of which exactly one is chosen. */
595
- function MenuRadioGroup({ value, onValueChange, className, children, ...props }: MenuRadioGroupProps) {
595
+ export function MenuRadioGroup({ value, onValueChange, className, children, ...props }: MenuRadioGroupProps) {
596
596
  const context = useMemo(
597
597
  () => ({ value, select: (next: string) => onValueChange?.(next) }),
598
598
  [value, onValueChange]
@@ -616,7 +616,7 @@ export interface MenuRadioItemProps extends Omit<MenuItemProps, 'icon' | 'inset'
616
616
  }
617
617
 
618
618
  /** One option inside a `Menu.RadioGroup`. */
619
- function MenuRadioItem({
619
+ export function MenuRadioItem({
620
620
  value,
621
621
  indicator = 'check',
622
622
  onSelect,
@@ -694,7 +694,7 @@ export interface MenuSubProps {
694
694
  }
695
695
 
696
696
  /** Groups a `Menu.SubTrigger` with the rows it reveals. */
697
- function MenuSub({ children, defaultOpen = false, open, onOpenChange }: MenuSubProps) {
697
+ export function MenuSub({ children, defaultOpen = false, open, onOpenChange }: MenuSubProps) {
698
698
  const [internalOpen, setInternalOpen] = useState(defaultOpen);
699
699
  const isControlled = open !== undefined;
700
700
  const resolved = isControlled ? open : internalOpen;
@@ -723,7 +723,7 @@ export interface MenuSubTriggerProps
723
723
  * point down once open, so the row states which way its rows will appear
724
724
  * rather than only that it has some.
725
725
  */
726
- function MenuSubTrigger({ className, children, icon, onSelect, ...props }: MenuSubTriggerProps) {
726
+ export function MenuSubTrigger({ className, children, icon, onSelect, ...props }: MenuSubTriggerProps) {
727
727
  const { open, toggle } = useMenuSub('Menu.SubTrigger');
728
728
  const sign = useDirectionSign();
729
729
  const direction = useDirection();
@@ -790,7 +790,7 @@ export interface MenuSubContentProps extends ViewProps {
790
790
  * would otherwise report the animated height back, and the panel would settle
791
791
  * at whatever it happened to measure on the first frame.
792
792
  */
793
- function MenuSubContent({ className, children, ...props }: MenuSubContentProps) {
793
+ export function MenuSubContent({ className, children, ...props }: MenuSubContentProps) {
794
794
  const { open } = useMenuSub('Menu.SubContent');
795
795
  const reducedMotion = useReducedMotion();
796
796
  const [height, setHeight] = useState(0);
@@ -841,6 +841,13 @@ MenuSub.displayName = 'Menu.Sub';
841
841
  MenuSubTrigger.displayName = 'Menu.SubTrigger';
842
842
  MenuSubContent.displayName = 'Menu.SubContent';
843
843
 
844
+ /*
845
+ * The parts above are exported individually as well as hung off `Menu`, and the
846
+ * reason is `ContextMenu`: it presents these same components as its own rows, so
847
+ * the emitted type declarations for it have to be able to name them. `Menu` is
848
+ * still the only thing the package exports, so nothing about how these are
849
+ * reached from outside has changed.
850
+ */
844
851
  export const Menu = Object.assign(MenuRoot, {
845
852
  Trigger: MenuTrigger,
846
853
  Content: MenuContent,
@@ -72,17 +72,37 @@ const DEFAULT_OFFSET = 8;
72
72
  const SCREEN_MARGIN = 12;
73
73
  /** Side of the arrow square before it is rotated 45°. */
74
74
  const ARROW_SIZE = 12;
75
+ /**
76
+ * Headroom the sheet presentation leaves for the close button.
77
+ *
78
+ * That button floats over the sheet's top-end corner rather than sitting in the
79
+ * flow, so nothing below it is pushed out of its way. The sheet's own padding
80
+ * and grabber put the first child 24 points down; the button's lower edge is at
81
+ * 44. This is the difference, plus a little air — enough that a panel's first
82
+ * row clears the button instead of being drawn under it.
83
+ */
84
+ const SHEET_CLOSE_CLEARANCE = 24;
75
85
 
76
86
  export type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right';
77
87
  export type PopoverAlign = 'start' | 'center' | 'end';
78
88
 
79
- interface TriggerRect {
89
+ /**
90
+ * The rectangle the panel is placed against, in window coordinates.
91
+ *
92
+ * Normally the trigger's own bounds. A zero-sized rect is meaningful too: it
93
+ * anchors the panel to a single point, which is what a menu opened by a long
94
+ * press on arbitrary content needs — there the interesting position is where
95
+ * the finger landed, not the bounds of whatever it landed on.
96
+ */
97
+ export interface PopoverAnchorRect {
80
98
  x: number;
81
99
  y: number;
82
100
  width: number;
83
101
  height: number;
84
102
  }
85
103
 
104
+ type TriggerRect = PopoverAnchorRect;
105
+
86
106
  interface PopoverContextValue {
87
107
  open: boolean;
88
108
  setOpen: (open: boolean) => void;
@@ -110,6 +130,38 @@ function usePopover(component: string): PopoverContextValue {
110
130
  return context;
111
131
  }
112
132
 
133
+ export interface PopoverAnchorControls {
134
+ open: boolean;
135
+ setOpen: (open: boolean) => void;
136
+ /**
137
+ * Place the panel against an explicit rect rather than against a measured
138
+ * trigger. Pass a zero-sized rect to anchor it to a point.
139
+ */
140
+ anchorTo: (rect: PopoverAnchorRect) => void;
141
+ }
142
+
143
+ /**
144
+ * For a trigger that opens the panel on something other than a plain press, or
145
+ * anchors it to something other than its own bounds.
146
+ *
147
+ * `Popover.Trigger` covers the ordinary case — press the thing, measure the
148
+ * thing, open next to it. A component built on this one may need neither half
149
+ * of that: a context menu opens on a long press and belongs at the point the
150
+ * finger landed. Rather than have it own a second copy of the placing,
151
+ * flipping and edge-clamping this file already does, it borrows them by
152
+ * setting the anchor itself.
153
+ *
154
+ * Only useful inside a `Popover`, which is the same rule every other part here
155
+ * follows.
156
+ */
157
+ export function usePopoverAnchor(component: string): PopoverAnchorControls {
158
+ const { open, setOpen, setTrigger } = usePopover(component);
159
+ return useMemo(
160
+ () => ({ open, setOpen, anchorTo: setTrigger }),
161
+ [open, setOpen, setTrigger]
162
+ );
163
+ }
164
+
113
165
  export interface PopoverProps {
114
166
  children: ReactNode;
115
167
  /** Controlled open state. */
@@ -271,6 +323,18 @@ export interface PopoverContentProps extends ViewProps {
271
323
  * not, so it is safe to pass either way.
272
324
  */
273
325
  blur?: boolean;
326
+ /**
327
+ * Dim the screen behind the panel.
328
+ *
329
+ * Off by default: a popover is a panel *beside* something, and dimming the
330
+ * page says the thing behind it has stopped being available — which is a
331
+ * dialog's claim, not a popover's. Worth turning on when the panel is the
332
+ * only thing that matters while it is up, which is what a menu opened on the
333
+ * content itself is. Ignored under `blur`, which draws its own dim.
334
+ */
335
+ scrim?: boolean;
336
+ /** The dim's classes, when `scrim` is set. */
337
+ scrimClassName?: string;
274
338
  children?: ReactNode;
275
339
  }
276
340
 
@@ -288,6 +352,8 @@ function PopoverContent({
288
352
  background,
289
353
  dismissible = true,
290
354
  blur = false,
355
+ scrim = false,
356
+ scrimClassName = 'bg-black/30',
291
357
  children,
292
358
  onLayout: onLayoutProp,
293
359
  style,
@@ -342,6 +408,23 @@ function PopoverContent({
342
408
  ? Math.min(Math.max(requestedWidth, minWidth), available)
343
409
  : requestedWidth;
344
410
 
411
+ /*
412
+ * The floor also has to reach a panel sized to its own contents, which is the
413
+ * case it matters most in — a panel with a width already knows how wide it is.
414
+ *
415
+ * Folding it into `width` would be wrong: that pins the panel open at exactly
416
+ * the floor and stops it growing for content that needs more. It is a real
417
+ * minimum instead, left off when there is nothing to enforce so a content-fit
418
+ * panel keeps shrinking to fit.
419
+ *
420
+ * Without this a `content-fit` panel takes its width from whatever inside it
421
+ * is *not* flexible. A row of a flexible label and a fixed glyph collapses to
422
+ * the glyph, and the panel comes up as a strip of icons with the words
423
+ * squeezed out of it.
424
+ */
425
+ const resolvedMinWidth =
426
+ minWidth === undefined ? undefined : Math.min(minWidth, available);
427
+
345
428
  /*
346
429
  * The cap is what keeps a tall panel reachable. `place` clamps the panel
347
430
  * inside the bounds, but a panel taller than the bounds cannot be clamped
@@ -434,9 +517,7 @@ function PopoverContent({
434
517
  width === 'full' || width === 'trigger' ? { width: '100%' } : null,
435
518
  typeof width === 'number' ? { width } : null,
436
519
  minWidth === undefined ? null : { minWidth },
437
- // Clear of the close button, which floats over the top-end
438
- // corner of the sheet rather than sitting in the flow.
439
- { paddingTop: 8 },
520
+ { paddingTop: SHEET_CLOSE_CLEARANCE },
440
521
  style,
441
522
  ]}
442
523
  >
@@ -458,8 +539,14 @@ function PopoverContent({
458
539
  <PopoverContext.Provider value={context}>
459
540
  <View className="absolute inset-0">
460
541
  {/* A popover does not dim the screen by default — the backdrop is
461
- there only to catch the outside tap. `blur` opts into a frost. */}
462
- {blur ? <Scrim blur intensity={20} dimClassName="bg-black/30" /> : null}
542
+ there only to catch the outside tap. `blur` opts into a frost,
543
+ `scrim` into a plain dim; the frost already draws one of its own,
544
+ so asking for both is not two dims. */}
545
+ {blur ? (
546
+ <Scrim blur intensity={20} dimClassName={scrimClassName} />
547
+ ) : scrim ? (
548
+ <Scrim dimClassName={scrimClassName} />
549
+ ) : null}
463
550
  <Pressable
464
551
  accessibilityLabel="Close"
465
552
  className="absolute inset-0"
@@ -486,6 +573,7 @@ function PopoverContent({
486
573
  maxWidth: bounds.right - bounds.left,
487
574
  maxHeight: resolvedMaxHeight,
488
575
  width: resolvedWidth,
576
+ minWidth: resolvedMinWidth,
489
577
  }}
490
578
  >
491
579
  <Animated.View
@@ -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: 22, stiffness: 300, mass: 0.7 } as const;
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
- * Where the dragged row belongs now, given where its middle has reached.
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 *not* passed the middle of, rather than scanning the whole
229
- * list for the nearest slot. The difference shows up with rows of unequal
230
- * height: scanning can hand back a slot two places away that happens to be
231
- * closer, which reads as the row skipping one.
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
- center: number,
313
+ top: number,
314
+ height: number,
237
315
  heights: Record<string, number>,
238
316
  gap: number
239
317
  ): number {
240
318
  'worklet';
241
- if (center < slotCenter(order, current, heights, gap)) {
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 (center >= slotCenter(order, i, heights, gap)) break;
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 (center <= slotCenter(order, i, heights, gap)) break;
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);
@@ -487,6 +591,47 @@ function SortableRoot({
487
591
  [heights]
488
592
  );
489
593
 
594
+ const setPinned = useCallback(
595
+ (id: string, next: boolean) => {
596
+ if (Boolean(pinned.value[id]) === next) return;
597
+ pinned.value = { ...pinned.value, [id]: next };
598
+ },
599
+ [pinned]
600
+ );
601
+
602
+ /*
603
+ * Every row's distance from where it was laid out, in one pass.
604
+ *
605
+ * Two prefix sums — one over the order being dragged, one over the order the
606
+ * children are actually in — and the difference between them per id. Rebuilt
607
+ * when a swap changes `order`, when a drop resets both, or when a row reports
608
+ * a new height, and at no other time; a drag that is only moving the carried
609
+ * row does not touch it at all.
610
+ */
611
+ const offsets = useDerivedValue<Record<string, number>>(() => {
612
+ const map = heights.value;
613
+ const target: Record<string, number> = {};
614
+ const list = order.value;
615
+ let at = 0;
616
+ for (let i = 0; i < list.length; i += 1) {
617
+ const id = list[i];
618
+ if (id === undefined) continue;
619
+ target[id] = at;
620
+ at += (map[id] ?? 0) + gap;
621
+ }
622
+
623
+ const result: Record<string, number> = {};
624
+ const laid = rendered.value;
625
+ at = 0;
626
+ for (let i = 0; i < laid.length; i += 1) {
627
+ const id = laid[i];
628
+ if (id === undefined) continue;
629
+ result[id] = (target[id] ?? at) - at;
630
+ at += (map[id] ?? 0) + gap;
631
+ }
632
+ return result;
633
+ }, [gap]);
634
+
490
635
  /* ---------------------------------------------------------------------- */
491
636
  /* Autoscroll */
492
637
  /* ---------------------------------------------------------------------- */
@@ -607,6 +752,8 @@ function SortableRoot({
607
752
  order,
608
753
  rendered,
609
754
  heights,
755
+ pinned,
756
+ offsets,
610
757
  activeId,
611
758
  translate,
612
759
  lift,
@@ -623,6 +770,7 @@ function SortableRoot({
623
770
  activeItem,
624
771
  indexOf,
625
772
  measured,
773
+ setPinned,
626
774
  begin,
627
775
  settled,
628
776
  step,
@@ -632,6 +780,8 @@ function SortableRoot({
632
780
  order,
633
781
  rendered,
634
782
  heights,
783
+ pinned,
784
+ offsets,
635
785
  activeId,
636
786
  translate,
637
787
  lift,
@@ -648,6 +798,7 @@ function SortableRoot({
648
798
  activeItem,
649
799
  indexOf,
650
800
  measured,
801
+ setPinned,
651
802
  begin,
652
803
  settled,
653
804
  step,
@@ -686,10 +837,20 @@ export interface SortableItemProps extends Omit<ViewProps, 'children'> {
686
837
  /**
687
838
  * Stop this row being picked up. The others still move past it, because a
688
839
  * row that cannot be dragged is not the same as a row that cannot be
689
- * displaced — a pinned row is a different feature, and pretending this one
690
- * is it would mean silently refusing drops that look like they worked.
840
+ * displaced — that is what `pinned` is for, and conflating the two would mean
841
+ * silently refusing drops that look like they worked.
691
842
  */
692
843
  disabled?: boolean;
844
+ /**
845
+ * Hold this row's place in the list. It cannot be picked up, and — unlike a
846
+ * `disabled` row — nothing else can take its slot either: the rows being
847
+ * dragged reorder among the places left over, and one carried past this row
848
+ * goes around it rather than through it.
849
+ *
850
+ * For the row that means something by being where it is. A header, a total, a
851
+ * step that has to come first.
852
+ */
853
+ pinned?: boolean;
693
854
  /**
694
855
  * Extra classes for the row while it is being carried, applied last. A
695
856
  * lifted row is given an opaque surface and a shadow so it is never drawn
@@ -711,6 +872,7 @@ function SortableItem({
711
872
  id,
712
873
  children,
713
874
  disabled = false,
875
+ pinned = false,
714
876
  ...props
715
877
  }: SortableItemProps) {
716
878
  const root = useSortableRoot('Sortable.Item');
@@ -718,6 +880,8 @@ function SortableItem({
718
880
  order,
719
881
  rendered,
720
882
  heights,
883
+ pinned: pinnedIds,
884
+ offsets,
721
885
  activeId,
722
886
  translate,
723
887
  lift,
@@ -733,13 +897,23 @@ function SortableItem({
733
897
  activeItem,
734
898
  indexOf,
735
899
  measured,
900
+ setPinned,
736
901
  begin,
737
902
  settled,
738
903
  step,
739
904
  } = root;
740
905
 
741
- /** A row is undraggable if either it or the whole list says so. */
742
- const locked = root.disabled || disabled;
906
+ /** A row is undraggable if it, the whole list, or its own pin says so. */
907
+ const locked = root.disabled || disabled || pinned;
908
+
909
+ /*
910
+ * Published to the root so the drag can read it on the UI thread. A pin is
911
+ * resolved while a finger is moving, where the props of a row two places away
912
+ * are not reachable.
913
+ */
914
+ useEffect(() => {
915
+ setPinned(id, pinned);
916
+ }, [id, pinned, setPinned]);
743
917
  const index = indexOf(id);
744
918
  const isActive = activeItem === id;
745
919
 
@@ -811,13 +985,20 @@ function SortableItem({
811
985
  if (current < 0) return;
812
986
 
813
987
  const top = slotOffset(rendered.value, id, map, gap) + translate.value;
814
- const center = top + (map[id] ?? 0) / 2;
815
- const to = targetIndex(list, current, center, map, gap);
988
+ const to = targetIndex(list, current, top, map[id] ?? 0, map, gap);
816
989
  if (to === current) return;
817
990
 
818
- const next = [...list];
819
- next.splice(current, 1);
820
- next.splice(to, 0, id);
991
+ const next = moveWithPinned(
992
+ list,
993
+ rendered.value,
994
+ pinnedIds.value,
995
+ id,
996
+ current,
997
+ to
998
+ );
999
+ // A move that only pinned rows could have absorbed leaves the order
1000
+ // exactly as it was, and there is nothing to feel or to redraw.
1001
+ if (next[current] === id) return;
821
1002
  order.value = next;
822
1003
 
823
1004
  if (haptics) runOnJS(notifyTick)();
@@ -914,9 +1095,14 @@ function SortableItem({
914
1095
  };
915
1096
  }
916
1097
 
917
- const map = heights.value;
918
- const offset =
919
- slotOffset(order.value, id, map, gap) - slotOffset(rendered.value, id, map, gap);
1098
+ /*
1099
+ * Read, not worked out. This worklet re-runs on every frame of a drag —
1100
+ * it closes over the value the carried row rides on — so summing the
1101
+ * heights above this row here, twice, made a list cost the square of its
1102
+ * length to drag. The root derives every row's offset in one pass instead,
1103
+ * and only when the arrangement actually changes.
1104
+ */
1105
+ const offset = offsets.value[id] ?? 0;
920
1106
 
921
1107
  /*
922
1108
  * Only animated while a drag is in flight, and the difference is the whole