cursedbelt 5.55.1 → 5.56.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 (36) hide show
  1. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  2. package/dist/react/file-tree/FileTree.js +7 -0
  3. package/dist/react/file-tree/FileTree.js.map +1 -1
  4. package/dist/react/file-tree/fileTree.css +14 -0
  5. package/dist/react/filter-rail/FilterRail.js +1 -1
  6. package/dist/react/filter-rail/FilterRail.js.map +1 -1
  7. package/dist/react/filter-rail/RailSection.js +2 -2
  8. package/dist/react/filter-rail/RailSection.js.map +1 -1
  9. package/dist/react/filter-rail/headerContextMenu.d.ts.map +1 -1
  10. package/dist/react/filter-rail/headerContextMenu.js +20 -1
  11. package/dist/react/filter-rail/headerContextMenu.js.map +1 -1
  12. package/dist/react/hooks/longPressContextMenu.d.ts +35 -0
  13. package/dist/react/hooks/longPressContextMenu.d.ts.map +1 -0
  14. package/dist/react/hooks/longPressContextMenu.js +136 -0
  15. package/dist/react/hooks/longPressContextMenu.js.map +1 -0
  16. package/dist/react/hooks/useLongPressContextMenu.d.ts +4 -0
  17. package/dist/react/hooks/useLongPressContextMenu.d.ts.map +1 -0
  18. package/dist/react/hooks/useLongPressContextMenu.js +12 -0
  19. package/dist/react/hooks/useLongPressContextMenu.js.map +1 -0
  20. package/dist/react/hooks/useScrollMemory.d.ts +5 -0
  21. package/dist/react/hooks/useScrollMemory.d.ts.map +1 -1
  22. package/dist/react/hooks/useScrollMemory.js +48 -23
  23. package/dist/react/hooks/useScrollMemory.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/react/file-tree/FileTree.spec.tsx +19 -0
  26. package/src/react/file-tree/FileTree.tsx +8 -0
  27. package/src/react/file-tree/fileTree.css +14 -0
  28. package/src/react/filter-rail/FilterRail.tsx +1 -1
  29. package/src/react/filter-rail/RailSection.tsx +2 -2
  30. package/src/react/filter-rail/headerContextMenu.spec.tsx +52 -0
  31. package/src/react/filter-rail/headerContextMenu.ts +19 -1
  32. package/src/react/hooks/longPressContextMenu.spec.ts +102 -0
  33. package/src/react/hooks/longPressContextMenu.ts +135 -0
  34. package/src/react/hooks/useLongPressContextMenu.ts +15 -0
  35. package/src/react/hooks/useScrollMemory.spec.tsx +46 -0
  36. package/src/react/hooks/useScrollMemory.ts +47 -20
@@ -0,0 +1,136 @@
1
+ /**
2
+ * A touch HOLD answers like a right-click: it dispatches a real `contextmenu` at the finger.
3
+ *
4
+ * Owner, 2026-10-04: *"on mobile I cannot hold control and click like I can on mac to make the
5
+ * menus popup, we need to make holding down on those spots pop up the menu on iphone and mobile
6
+ * viewers."* iOS Safari never fires `contextmenu` for a long-press, so every menu wired to it —
7
+ * the file tree's rows and blank space, the rail heading's view menu (show hidden folders) —
8
+ * was unreachable on an iPhone.
9
+ *
10
+ * 🔴 It DISPATCHES the event rather than calling a handler, so every listener already on the
11
+ * surface — a native one through a ref, React's `onContextMenu` on a descendant row — answers
12
+ * a hold exactly as it answers a right-click, and a new menu needs no touch code of its own.
13
+ *
14
+ * The rules, each one a failure it prevents:
15
+ * · touch and pen only — a mouse already has a right-click;
16
+ * · a finger that travels past {@link LONG_PRESS_SLOP_PX} is a scroll or a drag, not a hold;
17
+ * · a browser that fires its OWN `contextmenu` for a hold (Android Chrome) keeps it — the
18
+ * timer is dropped, so one hold never opens two menus; one that arrives AFTER ours fired is
19
+ * swallowed for the same reason;
20
+ * · the `click` the lift produces is swallowed at the window, before `useOutsidePress`'s
21
+ * document listener can read it as a press outside the menu that just opened and shut it;
22
+ * · `-webkit-touch-callout: none` while bound, so iOS does not put its own callout on top;
23
+ * · a press inside `ignore` (a selector) is not a hold — the file tree's grip, where a finger
24
+ * that stays down is about to drag.
25
+ */
26
+ /*
27
+ * 🔴 No runtime import, React's included: a rail heading loads this module LAZILY (forge task
28
+ * 2791), and a chunk with dependencies drags a preload map into the chunk that imports it. The
29
+ * hook lives in `useLongPressContextMenu.ts`.
30
+ */
31
+ /** How long a finger must stay down — iOS's own long-press duration. */
32
+ export const LONG_PRESS_MS = 500;
33
+ /** How far a finger may drift and still be holding still. */
34
+ export const LONG_PRESS_SLOP_PX = 8;
35
+ /** How long after a fired hold its lift's `click` and a late native menu are swallowed. */
36
+ const AFTERMATH_MS = 800;
37
+ /** Bind a hold-for-menu to `element`; returns the cleanup. */
38
+ export function bindLongPressContextMenu(element, { delayMs = LONG_PRESS_MS, ignore } = {}) {
39
+ let timer;
40
+ let pointerId = -1;
41
+ let startX = 0;
42
+ let startY = 0;
43
+ let target = null;
44
+ /** Until when a native `contextmenu` and a `click` are the aftermath of OUR menu. */
45
+ let firedUntil = 0;
46
+ let dispatching = false;
47
+ const cancel = () => {
48
+ if (timer !== undefined)
49
+ clearTimeout(timer);
50
+ timer = undefined;
51
+ };
52
+ const swallowClick = (event) => {
53
+ window.removeEventListener('click', swallowClick, true);
54
+ if (Date.now() > firedUntil)
55
+ return;
56
+ event.preventDefault();
57
+ event.stopImmediatePropagation();
58
+ };
59
+ const fire = () => {
60
+ timer = undefined;
61
+ if (!(target instanceof Element) || !target.isConnected)
62
+ return;
63
+ firedUntil = Date.now() + AFTERMATH_MS;
64
+ window.addEventListener('click', swallowClick, true);
65
+ setTimeout(() => window.removeEventListener('click', swallowClick, true), AFTERMATH_MS);
66
+ dispatching = true;
67
+ try {
68
+ target.dispatchEvent(new MouseEvent('contextmenu', {
69
+ bubbles: true,
70
+ cancelable: true,
71
+ composed: true,
72
+ clientX: startX,
73
+ clientY: startY,
74
+ button: 2,
75
+ }));
76
+ }
77
+ finally {
78
+ dispatching = false;
79
+ }
80
+ };
81
+ const onDown = (event) => {
82
+ cancel();
83
+ if (event.pointerType === 'mouse' || !event.isPrimary)
84
+ return;
85
+ if (ignore && event.target instanceof Element && event.target.closest(ignore))
86
+ return;
87
+ pointerId = event.pointerId;
88
+ startX = event.clientX;
89
+ startY = event.clientY;
90
+ target = event.target;
91
+ timer = setTimeout(fire, delayMs);
92
+ };
93
+ const onMove = (event) => {
94
+ if (timer === undefined || event.pointerId !== pointerId)
95
+ return;
96
+ if (Math.hypot(event.clientX - startX, event.clientY - startY) > LONG_PRESS_SLOP_PX)
97
+ cancel();
98
+ };
99
+ const onEnd = (event) => {
100
+ if (event.pointerId === pointerId)
101
+ cancel();
102
+ };
103
+ const onNativeMenu = (event) => {
104
+ if (dispatching)
105
+ return;
106
+ if (timer !== undefined) {
107
+ cancel(); // the browser answered the hold itself — that is the one menu
108
+ return;
109
+ }
110
+ if (Date.now() <= firedUntil) {
111
+ event.preventDefault();
112
+ event.stopImmediatePropagation();
113
+ }
114
+ };
115
+ const previousCallout = element.style.getPropertyValue('-webkit-touch-callout');
116
+ element.style.setProperty('-webkit-touch-callout', 'none');
117
+ element.addEventListener('pointerdown', onDown);
118
+ element.addEventListener('pointermove', onMove);
119
+ element.addEventListener('pointerup', onEnd);
120
+ element.addEventListener('pointercancel', onEnd);
121
+ element.addEventListener('contextmenu', onNativeMenu, true);
122
+ return () => {
123
+ cancel();
124
+ window.removeEventListener('click', swallowClick, true);
125
+ element.removeEventListener('pointerdown', onDown);
126
+ element.removeEventListener('pointermove', onMove);
127
+ element.removeEventListener('pointerup', onEnd);
128
+ element.removeEventListener('pointercancel', onEnd);
129
+ element.removeEventListener('contextmenu', onNativeMenu, true);
130
+ if (previousCallout)
131
+ element.style.setProperty('-webkit-touch-callout', previousCallout);
132
+ else
133
+ element.style.removeProperty('-webkit-touch-callout');
134
+ };
135
+ }
136
+ //# sourceMappingURL=longPressContextMenu.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"longPressContextMenu.js","sourceRoot":"","sources":["../../../src/react/hooks/longPressContextMenu.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ import { type RefObject } from 'react';
2
+ /** {@link bindLongPressContextMenu} on a ref's element while `enabled`. */
3
+ export declare function useLongPressContextMenu(ref: RefObject<HTMLElement | null>, enabled?: boolean, ignore?: string): void;
4
+ //# sourceMappingURL=useLongPressContextMenu.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useLongPressContextMenu.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useLongPressContextMenu.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAa,MAAM,OAAO,CAAC;AAGlD,2EAA2E;AAC3E,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,EAClC,OAAO,UAAO,EACd,MAAM,CAAC,EAAE,MAAM,GACd,IAAI,CAMN"}
@@ -0,0 +1,12 @@
1
+ import { useEffect } from 'react';
2
+ import { bindLongPressContextMenu } from './longPressContextMenu.js';
3
+ /** {@link bindLongPressContextMenu} on a ref's element while `enabled`. */
4
+ export function useLongPressContextMenu(ref, enabled = true, ignore) {
5
+ useEffect(() => {
6
+ const element = ref.current;
7
+ if (!element || !enabled)
8
+ return;
9
+ return bindLongPressContextMenu(element, ignore ? { ignore } : {});
10
+ }, [ref, enabled, ignore]);
11
+ }
12
+ //# sourceMappingURL=useLongPressContextMenu.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useLongPressContextMenu.js","sourceRoot":"","sources":["../../../src/react/hooks/useLongPressContextMenu.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,SAAS,EAAE,MAAM,OAAO,CAAC;AAClD,OAAO,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAErE,2EAA2E;AAC3E,MAAM,UAAU,uBAAuB,CACrC,GAAkC,EAClC,OAAO,GAAG,IAAI,EACd,MAAe;IAEf,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,CAAC;QAC5B,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO;YAAE,OAAO;QACjC,OAAO,wBAAwB,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IACrE,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC;AAC7B,CAAC"}
@@ -27,6 +27,11 @@ import { type RefObject } from 'react';
27
27
  * until {@link ScrollMemoryOptions.deadlineMs}. Keep the list's pages across the trip
28
28
  * (`usePagedList` does, through the query cache) and it lands in the first frame.
29
29
  * - **The viewer always wins**: a touch, wheel, key or pointer press while it waits cancels it.
30
+ * - **Landed means landed.** For {@link HOLD_MS} after the restore reaches its target, a scroll
31
+ * the viewer did not make is put back. 🔴 music's phone list came back from Lyrics on 12,343px
32
+ * and sat at 12,496px 800ms later, about one gate run in 17 under load (forge task 2784) — a
33
+ * late write by something on the page (a virtualizer settling, the browser's scroll anchoring)
34
+ * that no instrumented run could catch in the act. Whatever writes it, the viewer's place wins.
30
35
  * - **A replaced URL is the same place**: when the location changes but the history entry does
31
36
  * not (a filter written with `replaceState`), the scroll is left alone.
32
37
  *
@@ -1 +1 @@
1
- {"version":3,"file":"useScrollMemory.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useScrollMemory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAA2B,MAAM,OAAO,CAAC;AAEhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,IAAI,CA0BzF;AAoED,oGAAoG;AACpG,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,WAAW,GAAG,IAAI,CAAC,CAAC;AAEzG,MAAM,WAAW,mBAAmB;IAClC,mEAAmE;IACnE,QAAQ,CAAC,EAAE,oBAAoB,CAAC;IAChC;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA6CD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAIzD"}
1
+ {"version":3,"file":"useScrollMemory.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useScrollMemory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAA2B,MAAM,OAAO,CAAC;AAEhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,IAAI,CA0BzF;AAwED,oGAAoG;AACpG,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,WAAW,GAAG,IAAI,CAAC,CAAC;AAEzG,MAAM,WAAW,mBAAmB;IAClC,mEAAmE;IACnE,QAAQ,CAAC,EAAE,oBAAoB,CAAC;IAChC;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAmDD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAIzD"}
@@ -27,6 +27,11 @@ import { useLayoutEffect, useRef } from 'react';
27
27
  * until {@link ScrollMemoryOptions.deadlineMs}. Keep the list's pages across the trip
28
28
  * (`usePagedList` does, through the query cache) and it lands in the first frame.
29
29
  * - **The viewer always wins**: a touch, wheel, key or pointer press while it waits cancels it.
30
+ * - **Landed means landed.** For {@link HOLD_MS} after the restore reaches its target, a scroll
31
+ * the viewer did not make is put back. 🔴 music's phone list came back from Lyrics on 12,343px
32
+ * and sat at 12,496px 800ms later, about one gate run in 17 under load (forge task 2784) — a
33
+ * late write by something on the page (a virtualizer settling, the browser's scroll anchoring)
34
+ * that no instrumented run could catch in the act. Whatever writes it, the viewer's place wins.
30
35
  * - **A replaced URL is the same place**: when the location changes but the history entry does
31
36
  * not (a filter written with `replaceState`), the scroll is left alone.
32
37
  *
@@ -76,13 +81,17 @@ function begin(scroller, location, last, deadlineMs) {
76
81
  // the partial offset on screen — leaving mid-restore (or StrictMode's rehearsal unmount) must
77
82
  // not overwrite a deep position with a shallow one.
78
83
  let offset = scroller.get();
79
- let cancelRestore = null;
84
+ /** True until the restore has landed or given up — while it is, `offset` stays the target. */
85
+ let restoring = false;
86
+ /** Stops the restore, or the hold after it, reporting nothing. */
87
+ let stopRestore = null;
80
88
  if (!replacedInPlace) {
81
89
  const target = recalled(id);
82
90
  if (target > 0) {
83
91
  offset = target;
84
- cancelRestore = restore(scroller, target, deadlineMs, (outcome) => {
85
- cancelRestore = null;
92
+ restoring = true;
93
+ stopRestore = restore(scroller, target, deadlineMs, (outcome) => {
94
+ restoring = false;
86
95
  if (outcome !== 'reached')
87
96
  offset = scroller.get();
88
97
  });
@@ -93,7 +102,7 @@ function begin(scroller, location, last, deadlineMs) {
93
102
  }
94
103
  }
95
104
  const onScroll = () => {
96
- if (!cancelRestore)
105
+ if (!restoring)
97
106
  offset = scroller.get();
98
107
  };
99
108
  const onHide = () => file(id, offset);
@@ -103,9 +112,9 @@ function begin(scroller, location, last, deadlineMs) {
103
112
  location,
104
113
  target: scroller.events,
105
114
  end() {
106
- const pending = cancelRestore;
107
- cancelRestore = null;
108
- pending?.();
115
+ restoring = false;
116
+ stopRestore?.();
117
+ stopRestore = null;
109
118
  scroller.events.removeEventListener('scroll', onScroll);
110
119
  window.removeEventListener('pagehide', onHide);
111
120
  file(id, offset);
@@ -113,6 +122,12 @@ function begin(scroller, location, last, deadlineMs) {
113
122
  };
114
123
  }
115
124
  const RESTORE_DEADLINE_MS = 3_000;
125
+ /**
126
+ * How long a landed restore holds its target against a scroll the viewer did not make. Long
127
+ * enough for a list to finish settling (the late move in forge task 2784 came inside 800ms, under
128
+ * load); short enough that an app's own jump a moment later is not undone.
129
+ */
130
+ const HOLD_MS = 1_500;
116
131
  const ENTRY_KEY = 'cbScrollEntry';
117
132
  const STORE_KEY = 'cursedbelt:scroll-memory';
118
133
  /** Entries kept in `sessionStorage` — a tab's history, not a cache; the oldest go first. */
@@ -195,39 +210,51 @@ function file(id, y) {
195
210
  // Private mode, quota, blocked storage: memory still covers Back within this page load.
196
211
  }
197
212
  }
198
- /** Scroll to `target` once there is room for it; returns a cancel that reports nothing. */
213
+ /**
214
+ * Scroll to `target` once there is room for it, then HOLD it for {@link HOLD_MS}; returns a
215
+ * cancel that reports nothing. `settled` is called once, when it lands or gives up.
216
+ */
199
217
  function restore(scroller, target, deadlineMs, settled) {
200
218
  const deadline = performance.now() + deadlineMs;
201
219
  let frame = 0;
202
- let done = false;
203
- const stop = () => {
204
- if (done)
205
- return false;
206
- done = true;
220
+ let holdTimer;
221
+ let phase = 'waiting';
222
+ const finish = () => {
223
+ phase = 'done';
207
224
  cancelAnimationFrame(frame);
225
+ clearTimeout(holdTimer);
208
226
  for (const type of INPUT_EVENTS)
209
227
  window.removeEventListener(type, onInput);
210
- return true;
228
+ scroller.events.removeEventListener('scroll', onStray);
211
229
  };
212
230
  const onInput = () => {
213
- if (stop())
231
+ const was = phase;
232
+ finish();
233
+ if (was === 'waiting')
214
234
  settled('input');
215
235
  };
236
+ // Landed, and something that is not the viewer moved it: put it back.
237
+ const onStray = () => {
238
+ if (phase === 'holding' && Math.abs(scroller.get() - target) > 1)
239
+ scroller.set(target);
240
+ };
216
241
  const attempt = () => {
217
- if (done)
242
+ if (phase !== 'waiting')
218
243
  return;
219
244
  const room = scroller.room();
220
245
  if (room >= target) {
221
246
  scroller.set(target);
222
- if (stop())
223
- settled('reached');
247
+ phase = 'holding';
248
+ scroller.events.addEventListener('scroll', onStray, { passive: true });
249
+ holdTimer = setTimeout(finish, HOLD_MS);
250
+ settled('reached');
224
251
  return;
225
252
  }
226
253
  // Short of it: go as far as the page allows — that is what asks an infinite list for more.
227
254
  scroller.set(Math.max(0, room));
228
255
  if (performance.now() >= deadline) {
229
- if (stop())
230
- settled('deadline');
256
+ finish();
257
+ settled('deadline');
231
258
  }
232
259
  else
233
260
  frame = requestAnimationFrame(attempt);
@@ -235,8 +262,6 @@ function restore(scroller, target, deadlineMs, settled) {
235
262
  for (const type of INPUT_EVENTS)
236
263
  window.addEventListener(type, onInput, { passive: true });
237
264
  attempt();
238
- return () => {
239
- stop();
240
- };
265
+ return finish;
241
266
  }
242
267
  //# sourceMappingURL=useScrollMemory.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"useScrollMemory.js","sourceRoot":"","sources":["../../../src/react/hooks/useScrollMemory.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"useScrollMemory.js","sourceRoot":"","sources":["../../../src/react/hooks/useScrollMemory.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.55.1",
3
+ "version": "5.56.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -38,6 +38,9 @@ const TREE: FileTreeNode[] = [
38
38
  /** `SPRING_OPEN_MS` in `FileTree.tsx` — Finder's and Explorer's own figure. */
39
39
  const SPRING = 600;
40
40
 
41
+ /** Past `LONG_PRESS_MS` (500) — a literal, so the spec stands without the binder. */
42
+ const HOLD = 550;
43
+
41
44
  const tree = (props: Partial<React.ComponentProps<typeof FileTree>> = {}) =>
42
45
  render(
43
46
  <FileTree nodes={TREE} selectedId={null} onSelect={() => undefined} {...props} />,
@@ -1149,6 +1152,22 @@ describe('the keyboard, beyond walking', () => {
1149
1152
  expect(opened).toEqual(['simpsons', 'simpsons/screencaps']);
1150
1153
  });
1151
1154
 
1155
+ test('🔴 a finger HELD on a row opens its menu — iOS has no right-click', async () => {
1156
+ // Owner, 2026-10-04: *"on mobile I cannot hold control and click like I can on mac to make
1157
+ // the menus popup, we need to make holding down on those spots pop up the menu on iphone"*.
1158
+ const opened: Array<{ id: string; x: number }> = [];
1159
+ tree({ onRowMenu: (node, at) => opened.push({ id: node.id, x: at.x }) });
1160
+ fireEvent.pointerDown(screen.getByText('Dinos'), {
1161
+ pointerType: 'touch',
1162
+ pointerId: 9,
1163
+ isPrimary: true,
1164
+ clientX: 21,
1165
+ clientY: 4,
1166
+ });
1167
+ await new Promise((resolve) => setTimeout(resolve, HOLD));
1168
+ expect(opened).toEqual([{ id: 'dinos', x: 21 }]);
1169
+ });
1170
+
1152
1171
  test('🔴 the keyboard comes back from a rename instead of landing on <body>', () => {
1153
1172
  // Committing unmounts the focused `<input>`, and a browser hands focus to `<body>` —
1154
1173
  // which is not in the tree, so every key this component defines is gone until something
@@ -197,6 +197,7 @@ import {
197
197
  import { useInView } from '../media/previewGate.js';
198
198
  import { lazyPart } from '../components/DataTable/lazyPart.js';
199
199
  import { applyFolderView, tagFilterActive } from './folderView.js';
200
+ import { useLongPressContextMenu } from '../hooks/useLongPressContextMenu.js';
200
201
  import {
201
202
  type FileTreeMenuItem,
202
203
  FolderViewStrip,
@@ -1811,6 +1812,13 @@ export function FileTree({
1811
1812
 
1812
1813
  /** Fetch the panels once the page is idle, if this tree has any menu to open. */
1813
1814
  const hasMenus = wantsBlankMenu || Boolean(rowMenu);
1815
+
1816
+ /*
1817
+ * A finger held on a row or on the blank space opens the same menu a right-click does —
1818
+ * iOS has no right-click and never fires `contextmenu` for a hold (owner, 2026-10-04). The
1819
+ * grip is excluded: a finger held there is about to drag. See `longPressContextMenu.ts`.
1820
+ */
1821
+ useLongPressContextMenu(scrollRef, hasMenus || Boolean(onRowMenu), '[data-cbft-grip]');
1814
1822
  useEffect(() => {
1815
1823
  if (!hasMenus) return;
1816
1824
  const load = () => void TreePanel.preload().catch(() => {});
@@ -629,6 +629,20 @@
629
629
  white-space: nowrap;
630
630
  }
631
631
 
632
+ /*
633
+ * A finger held on a row opens its menu (`longPressContextMenu.ts`), so on a touch screen the
634
+ * hold must not ALSO start selecting the name under it. A coarse pointer only: a mouse may
635
+ * still drag-select a name to copy it.
636
+ */
637
+ @media (pointer: coarse) {
638
+ .cbft-row,
639
+ .cbft-name,
640
+ .cbft-suffix {
641
+ -webkit-user-select: none;
642
+ user-select: none;
643
+ }
644
+ }
645
+
632
646
  /*
633
647
  * A derived extension, a step quieter than the name — see `FileTreeNode.suffix`. Quieter
634
648
  * because the name is what you scan FOR and the suffix is what you scan BY; at full
@@ -639,7 +639,7 @@ function SectionHeading({
639
639
  }): ReactNode {
640
640
  const ref = useHeaderContextMenu(onContextMenu);
641
641
  return (
642
- <div ref={ref} className='flex items-center justify-between gap-1 px-2 pt-1 pb-1'>
642
+ <div ref={ref} className='flex select-none items-center justify-between gap-1 px-2 pt-1 pb-1'>
643
643
  <span className='truncate text-xs font-semibold uppercase tracking-wide text-muted-foreground'>
644
644
  {label}
645
645
  </span>
@@ -116,7 +116,7 @@ export function RailSection({
116
116
  if (!collapsible)
117
117
  return (
118
118
  <section aria-labelledby={headingId} className={className} data-cb-rail-section='static'>
119
- <div ref={headerRef} className={cn('flex items-center justify-between gap-1 px-2 pt-1 pb-1', headerClassName)}>
119
+ <div ref={headerRef} className={cn('flex select-none items-center justify-between gap-1 px-2 pt-1 pb-1', headerClassName)}>
120
120
  <h2 className={cn('m-0', heading)} id={headingId}>
121
121
  {label}
122
122
  </h2>
@@ -131,7 +131,7 @@ export function RailSection({
131
131
  // a sidebar would pay for its presence machinery in its first chunk.
132
132
  return (
133
133
  <section aria-labelledby={headingId} className={className} data-cb-rail-section={open ? 'open' : 'closed'}>
134
- <div ref={headerRef} className={cn('flex items-center gap-1 pt-1 pb-1', headerClassName)}>
134
+ <div ref={headerRef} className={cn('flex select-none items-center gap-1 pt-1 pb-1', headerClassName)}>
135
135
  <h2 className='m-0 flex min-w-0 flex-1'>
136
136
  <button
137
137
  // guardrails-ignore no-raw-action-button: the section's heading IS its toggle — a
@@ -9,6 +9,9 @@ import { RailSection } from './RailSection.js';
9
9
  * bar of the file tree"*). Without the prop, the browser's menu stays.
10
10
  */
11
11
 
12
+ /** Past `LONG_PRESS_MS` (500) — a literal, so the spec stands without the binder. */
13
+ const HOLD = 550;
14
+
12
15
  afterEach(cleanup);
13
16
 
14
17
  const heading = (text: string) => screen.getByText(text).closest('div') as HTMLElement;
@@ -60,3 +63,52 @@ describe('a section heading\'s right-click', () => {
60
63
  expect(fireEvent.contextMenu(heading('Files'))).toBe(true);
61
64
  });
62
65
  });
66
+
67
+ describe('a section heading held by a finger', () => {
68
+ test('🔴 a touch held on the heading opens its menu — iOS never fires contextmenu for a hold', async () => {
69
+ // Owner, 2026-10-04: *"on mobile I cannot hold control and click like I can on mac to make
70
+ // the menus popup"*. Collections' "Files" heading is where hidden folders are shown again.
71
+ const seen: number[] = [];
72
+ render(
73
+ <FilterRail
74
+ label='Library'
75
+ selected={null}
76
+ onSelect={() => undefined}
77
+ sections={[
78
+ {
79
+ id: 'files',
80
+ label: 'Files',
81
+ onHeaderContextMenu: (event) => {
82
+ event.preventDefault();
83
+ seen.push(event.clientX);
84
+ },
85
+ },
86
+ ]}
87
+ />,
88
+ );
89
+ // The binder is a lazy chunk (forge task 2791) — let it land, as it does long before a hold.
90
+ await import('../hooks/longPressContextMenu.js');
91
+ await new Promise((resolve) => setTimeout(resolve, 0));
92
+ fireEvent.pointerDown(screen.getByText('Files'), {
93
+ pointerType: 'touch',
94
+ pointerId: 3,
95
+ isPrimary: true,
96
+ clientX: 17,
97
+ });
98
+ await new Promise((resolve) => setTimeout(resolve, HOLD));
99
+ expect(seen).toEqual([17]);
100
+ });
101
+ });
102
+
103
+ describe('the hold binder stays off the entry', () => {
104
+ test('🔴 headerContextMenu imports longPressContextMenu lazily, never statically', async () => {
105
+ // Every rail loads this hook in its app's entry; a static import put the binder on roms's
106
+ // entry (+0.59 kB gzip) for a menu roms never passes (forge task 2791).
107
+ const source = await Bun.file(new URL('./headerContextMenu.ts', import.meta.url)).text();
108
+ expect(source).not.toMatch(/^import[^;]*longPressContextMenu/m);
109
+ expect(source).toContain("import('../hooks/longPressContextMenu.js')");
110
+ // …and the binder imports nothing at runtime, or its chunk's preload map lands in the entry.
111
+ const binder = await Bun.file(new URL('../hooks/longPressContextMenu.ts', import.meta.url)).text();
112
+ expect(binder).not.toMatch(/^import (?!type )/m);
113
+ });
114
+ });
@@ -11,6 +11,15 @@
11
11
  * heading is a label, not a control, and giving it a role to hang a handler on would announce
12
12
  * it to a screen reader as something to press. The menu it opens is also reachable from the
13
13
  * tree itself (its blank space, and Shift+F10 on a row), so nothing is pointer-only.
14
+ *
15
+ * A finger HELD on the heading opens it too — iOS has no right-click (owner, 2026-10-04: *"on
16
+ * mobile I cannot hold control and click … make holding down on those spots pop up the menu"*).
17
+ * See `longPressContextMenu.ts`.
18
+ *
19
+ * 🔴 The binder is IMPORTED LAZILY, only once a heading has a menu: every app with a rail loads
20
+ * this hook in its entry, and most (roms's library) never pass a handler — a static import put
21
+ * 0.59 kB gzip on roms's entry for a menu it does not have (forge task 2791). A hold takes
22
+ * 500ms, so the chunk is in long before a finger could need it.
14
23
  */
15
24
  import { useEffect, useRef } from 'react';
16
25
 
@@ -31,7 +40,16 @@ export function useHeaderContextMenu(handler: HeaderContextMenuHandler | undefin
31
40
  if (!el || !wanted) return;
32
41
  const listener = (event: MouseEvent) => latest.current?.(event);
33
42
  el.addEventListener('contextmenu', listener);
34
- return () => el.removeEventListener('contextmenu', listener);
43
+ let unbindHold: (() => void) | undefined;
44
+ let gone = false;
45
+ void import('../hooks/longPressContextMenu.js').then(({ bindLongPressContextMenu }) => {
46
+ if (!gone) unbindHold = bindLongPressContextMenu(el);
47
+ });
48
+ return () => {
49
+ gone = true;
50
+ el.removeEventListener('contextmenu', listener);
51
+ unbindHold?.();
52
+ };
35
53
  }, [wanted]);
36
54
  return ref;
37
55
  }