@sovereignfs/ui 0.54.0 → 0.83.1

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 (277) hide show
  1. package/dist/Accordion.module.css +1 -1
  2. package/dist/Alert.module.css +1 -1
  3. package/dist/AppsLauncher.module.css +96 -0
  4. package/dist/Avatar.module.css +1 -1
  5. package/dist/Badge.module.css +30 -7
  6. package/dist/BalanceChip.module.css +1 -1
  7. package/dist/Button.module.css +18 -2
  8. package/dist/Card.module.css +1 -1
  9. package/dist/CardTile.module.css +181 -0
  10. package/dist/CodeTextarea.module.css +1 -1
  11. package/dist/Collapsible.module.css +1 -1
  12. package/dist/ColorPicker.module.css +132 -0
  13. package/dist/Combobox.module.css +2 -2
  14. package/dist/Command.module.css +1 -1
  15. package/dist/ConfirmDialog.module.css +2 -2
  16. package/dist/ConsentPrompt.module.css +21 -0
  17. package/dist/DatePicker.module.css +1 -1
  18. package/dist/Dialog.module.css +130 -21
  19. package/dist/Drawer.module.css +36 -7
  20. package/dist/Header.module.css +62 -0
  21. package/dist/HeaderFooterLayout.module.css +21 -0
  22. package/dist/IconPicker.module.css +1 -1
  23. package/dist/Input.module.css +1 -1
  24. package/dist/Kbd.module.css +1 -1
  25. package/dist/Markdown.module.css +24 -0
  26. package/dist/Menu.module.css +1 -0
  27. package/dist/Message.module.css +1 -1
  28. package/dist/MessageScroller.module.css +12 -1
  29. package/dist/MobileAppsDrawer.module.css +10 -9
  30. package/dist/MobileFooter.module.css +1 -1
  31. package/dist/MobileHeader.module.css +14 -1
  32. package/dist/NavList.module.css +130 -0
  33. package/dist/NavTabs.module.css +1 -1
  34. package/dist/NotificationsPanel.module.css +258 -0
  35. package/dist/OverlayHeader.module.css +1 -1
  36. package/dist/PageContainer.module.css +62 -0
  37. package/dist/PageHeader.module.css +54 -0
  38. package/dist/PageLayout.module.css +51 -0
  39. package/dist/Popover.module.css +1 -1
  40. package/dist/QuantityStepper.module.css +1 -1
  41. package/dist/Resizable.module.css +3 -3
  42. package/dist/RootLayout.module.css +12 -0
  43. package/dist/SegmentedControl.module.css +1 -1
  44. package/dist/Select.module.css +1 -1
  45. package/dist/Sheet.module.css +4 -1
  46. package/dist/SplitPane.module.css +4 -4
  47. package/dist/StatusBadge.module.css +1 -1
  48. package/dist/StepStrip.module.css +53 -0
  49. package/dist/SuggestionInput.module.css +2 -2
  50. package/dist/SwipableMobileCarousel.module.css +12 -3
  51. package/dist/SwipableMobileCarouselDots.module.css +15 -0
  52. package/dist/SwipeStack.module.css +101 -0
  53. package/dist/SystemBanner.module.css +10 -0
  54. package/dist/Table.module.css +2 -2
  55. package/dist/Tabs.module.css +1 -1
  56. package/dist/TagInput.module.css +2 -2
  57. package/dist/Textarea.module.css +9 -1
  58. package/dist/ThreeColumnLayout.module.css +35 -0
  59. package/dist/Toast.module.css +62 -3
  60. package/dist/Tooltip.module.css +1 -1
  61. package/dist/UserMenu.module.css +90 -0
  62. package/dist/index.d.ts +1047 -45
  63. package/dist/index.js +3908 -1456
  64. package/dist/tokens/primitives.css +11 -0
  65. package/dist/tokens/semantic.css +32 -0
  66. package/dist/tokens/theme-presets.css +33 -0
  67. package/package.json +25 -9
  68. package/src/__tests__/package-json.test.ts +8 -0
  69. package/src/components/Accordion/Accordion.module.css +1 -1
  70. package/src/components/Alert/Alert.module.css +1 -1
  71. package/src/components/AppsLauncher/AppsLauncher.module.css +96 -0
  72. package/src/components/AppsLauncher/AppsLauncher.stories.tsx +58 -0
  73. package/src/components/AppsLauncher/AppsLauncher.tsx +117 -0
  74. package/src/components/AppsLauncher/__tests__/AppsLauncher.test.tsx +52 -0
  75. package/src/components/Avatar/Avatar.module.css +1 -1
  76. package/src/components/Badge/Badge.module.css +30 -7
  77. package/src/components/Badge/Badge.stories.tsx +16 -4
  78. package/src/components/Badge/Badge.tsx +7 -4
  79. package/src/components/Badge/__tests__/Badge.test.tsx +4 -2
  80. package/src/components/BalanceChip/BalanceChip.module.css +1 -1
  81. package/src/components/Button/Button.module.css +18 -2
  82. package/src/components/Card/Card.module.css +1 -1
  83. package/src/components/CardTile/CardTile.module.css +181 -0
  84. package/src/components/CardTile/CardTile.stories.tsx +86 -0
  85. package/src/components/CardTile/CardTile.tsx +128 -0
  86. package/src/components/CardTile/__tests__/CardTile.test.tsx +56 -0
  87. package/src/components/CodeTextarea/CodeTextarea.module.css +1 -1
  88. package/src/components/Collapsible/Collapsible.module.css +1 -1
  89. package/src/components/ColorPicker/ColorPicker.module.css +132 -0
  90. package/src/components/ColorPicker/ColorPicker.stories.tsx +80 -0
  91. package/src/components/ColorPicker/ColorPicker.tsx +157 -0
  92. package/src/components/ColorPicker/__tests__/ColorPicker.test.tsx +117 -0
  93. package/src/components/Combobox/Combobox.module.css +2 -2
  94. package/src/components/Command/Command.module.css +1 -1
  95. package/src/components/ConfirmDialog/ConfirmDialog.module.css +2 -2
  96. package/src/components/ConfirmDialog/ConfirmDialog.tsx +28 -2
  97. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +71 -3
  98. package/src/components/ConsentPrompt/ConsentPrompt.module.css +21 -0
  99. package/src/components/ConsentPrompt/ConsentPrompt.stories.tsx +31 -0
  100. package/src/components/ConsentPrompt/ConsentPrompt.tsx +66 -0
  101. package/src/components/ContextMenu/ContextMenu.tsx +1 -1
  102. package/src/components/DatePicker/DatePicker.module.css +1 -1
  103. package/src/components/Dialog/Dialog.module.css +130 -21
  104. package/src/components/Dialog/Dialog.stories.tsx +179 -2
  105. package/src/components/Dialog/Dialog.tsx +129 -35
  106. package/src/components/Dialog/__tests__/Dialog.test.tsx +190 -9
  107. package/src/components/DragHandleRow/DragHandleRow.tsx +2 -21
  108. package/src/components/Drawer/Drawer.module.css +36 -7
  109. package/src/components/Drawer/Drawer.tsx +9 -10
  110. package/src/components/Drawer/__tests__/Drawer.test.tsx +33 -6
  111. package/src/components/GripIcon/GripIcon.tsx +27 -0
  112. package/src/components/Header/Header.module.css +62 -0
  113. package/src/components/Header/Header.stories.tsx +452 -0
  114. package/src/components/Header/Header.tsx +102 -0
  115. package/src/components/Header/__tests__/Header.test.tsx +104 -0
  116. package/src/components/HeaderFooterLayout/HeaderFooterLayout.module.css +21 -0
  117. package/src/components/HeaderFooterLayout/HeaderFooterLayout.stories.tsx +111 -0
  118. package/src/components/HeaderFooterLayout/HeaderFooterLayout.tsx +54 -0
  119. package/src/components/HeaderFooterLayout/__tests__/HeaderFooterLayout.test.tsx +58 -0
  120. package/src/components/Icon/icons/arrow-left-right.tsx +23 -0
  121. package/src/components/Icon/icons/bold.tsx +20 -0
  122. package/src/components/Icon/icons/calendar.tsx +4 -4
  123. package/src/components/Icon/icons/carrot.tsx +3 -3
  124. package/src/components/Icon/icons/circle-chevron-left.tsx +21 -0
  125. package/src/components/Icon/icons/code.tsx +21 -0
  126. package/src/components/Icon/icons/copy.tsx +21 -0
  127. package/src/components/Icon/icons/credit-card.tsx +21 -0
  128. package/src/components/Icon/icons/database.tsx +22 -0
  129. package/src/components/Icon/icons/download.tsx +22 -0
  130. package/src/components/Icon/icons/file-text.tsx +24 -0
  131. package/src/components/Icon/icons/folder-closed.tsx +21 -0
  132. package/src/components/Icon/icons/folder-open.tsx +20 -0
  133. package/src/components/Icon/icons/folder.tsx +20 -0
  134. package/src/components/Icon/icons/folders.tsx +21 -0
  135. package/src/components/Icon/icons/hat-glasses.tsx +24 -0
  136. package/src/components/Icon/icons/history.tsx +22 -0
  137. package/src/components/Icon/icons/inbox.tsx +21 -0
  138. package/src/components/Icon/icons/index.ts +161 -1
  139. package/src/components/Icon/icons/italic.tsx +22 -0
  140. package/src/components/Icon/icons/layers.tsx +22 -0
  141. package/src/components/Icon/icons/layout-dashboard.tsx +23 -0
  142. package/src/components/Icon/icons/layout-grid.tsx +23 -0
  143. package/src/components/Icon/icons/link.tsx +21 -0
  144. package/src/components/Icon/icons/list-ordered.tsx +25 -0
  145. package/src/components/Icon/icons/list.tsx +25 -0
  146. package/src/components/Icon/icons/luggage.tsx +24 -0
  147. package/src/components/Icon/icons/map-pin.tsx +21 -0
  148. package/src/components/Icon/icons/minus.tsx +20 -0
  149. package/src/components/Icon/icons/paint-bucket.tsx +23 -0
  150. package/src/components/Icon/icons/panel-left.tsx +21 -0
  151. package/src/components/Icon/icons/pin.tsx +21 -0
  152. package/src/components/Icon/icons/rectangle-ellipsis.tsx +23 -0
  153. package/src/components/Icon/icons/refresh-cw.tsx +23 -0
  154. package/src/components/Icon/icons/rotate-cw.tsx +21 -0
  155. package/src/components/Icon/icons/route.tsx +22 -0
  156. package/src/components/Icon/icons/send.tsx +21 -0
  157. package/src/components/Icon/icons/share-2.tsx +24 -0
  158. package/src/components/Icon/icons/sheet.tsx +24 -0
  159. package/src/components/Icon/icons/square-kanban.tsx +23 -0
  160. package/src/components/Icon/icons/table.tsx +23 -0
  161. package/src/components/Icon/icons/user-round-plus.tsx +23 -0
  162. package/src/components/Icon/icons/users.tsx +23 -0
  163. package/src/components/IconPicker/IconPicker.module.css +1 -1
  164. package/src/components/Input/Input.module.css +1 -1
  165. package/src/components/Input/Input.tsx +8 -2
  166. package/src/components/Kbd/Kbd.module.css +1 -1
  167. package/src/components/Markdown/Markdown.module.css +24 -0
  168. package/src/components/Markdown/Markdown.stories.tsx +10 -0
  169. package/src/components/Markdown/Markdown.tsx +166 -29
  170. package/src/components/Markdown/__tests__/Markdown.test.tsx +144 -0
  171. package/src/components/Menu/Menu.module.css +1 -0
  172. package/src/components/Menu/Menu.tsx +72 -22
  173. package/src/components/Menu/__tests__/Menu.test.tsx +30 -0
  174. package/src/components/Menubar/Menubar.tsx +1 -1
  175. package/src/components/Message/Message.module.css +1 -1
  176. package/src/components/MessageScroller/MessageScroller.module.css +12 -1
  177. package/src/components/MessageScroller/MessageScroller.tsx +12 -0
  178. package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +10 -9
  179. package/src/components/MobileFooter/MobileFooter.module.css +1 -1
  180. package/src/components/MobileFooter/MobileFooter.tsx +15 -2
  181. package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +58 -0
  182. package/src/components/MobileHeader/MobileHeader.module.css +14 -1
  183. package/src/components/MobileHeader/MobileHeader.tsx +15 -2
  184. package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +56 -1
  185. package/src/components/NavList/NavList.module.css +130 -0
  186. package/src/components/NavList/NavList.tsx +136 -0
  187. package/src/components/NavList/__tests__/NavList.test.tsx +109 -0
  188. package/src/components/NavTabs/NavTabs.module.css +1 -1
  189. package/src/components/NotificationsPanel/NotificationsPanel.module.css +258 -0
  190. package/src/components/NotificationsPanel/NotificationsPanel.stories.tsx +61 -0
  191. package/src/components/NotificationsPanel/NotificationsPanel.tsx +187 -0
  192. package/src/components/NotificationsPanel/__tests__/NotificationsPanel.test.tsx +78 -0
  193. package/src/components/OverlayHeader/OverlayHeader.module.css +1 -1
  194. package/src/components/OverlayHeader/OverlayHeader.tsx +19 -4
  195. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +18 -0
  196. package/src/components/PageContainer/PageContainer.module.css +62 -0
  197. package/src/components/PageContainer/PageContainer.tsx +37 -4
  198. package/src/components/PageContainer/__tests__/PageContainer.test.tsx +41 -8
  199. package/src/components/PageHeader/PageHeader.module.css +54 -0
  200. package/src/components/PageHeader/PageHeader.tsx +146 -5
  201. package/src/components/PageHeader/__tests__/PageHeader.test.tsx +103 -0
  202. package/src/components/PageLayout/PageLayout.module.css +51 -0
  203. package/src/components/PageLayout/PageLayout.stories.tsx +73 -0
  204. package/src/components/PageLayout/PageLayout.tsx +51 -0
  205. package/src/components/PageLayout/__tests__/PageLayout.test.tsx +34 -0
  206. package/src/components/Popover/Popover.module.css +1 -1
  207. package/src/components/Popover/Popover.tsx +86 -4
  208. package/src/components/Popover/__tests__/Popover.test.tsx +165 -0
  209. package/src/components/QuantityStepper/QuantityStepper.module.css +1 -1
  210. package/src/components/Resizable/Resizable.module.css +3 -3
  211. package/src/components/RootLayout/RootLayout.module.css +12 -0
  212. package/src/components/RootLayout/RootLayout.stories.tsx +125 -0
  213. package/src/components/RootLayout/RootLayout.tsx +185 -0
  214. package/src/components/RootLayout/__tests__/RootLayout.test.tsx +161 -0
  215. package/src/components/SegmentedControl/SegmentedControl.module.css +1 -1
  216. package/src/components/Select/Select.module.css +1 -1
  217. package/src/components/Sheet/Sheet.module.css +4 -1
  218. package/src/components/Sheet/Sheet.tsx +5 -5
  219. package/src/components/SplitPane/SplitPane.module.css +4 -4
  220. package/src/components/StatusBadge/StatusBadge.module.css +1 -1
  221. package/src/components/StepStrip/StepStrip.module.css +53 -0
  222. package/src/components/StepStrip/StepStrip.tsx +74 -0
  223. package/src/components/SuggestionInput/SuggestionInput.module.css +2 -2
  224. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +12 -3
  225. package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +25 -9
  226. package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +81 -0
  227. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +15 -0
  228. package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +12 -1
  229. package/src/components/SwipeStack/SwipeStack.module.css +101 -0
  230. package/src/components/SwipeStack/SwipeStack.tsx +199 -0
  231. package/src/components/SwipeStack/SwipeStackCard.tsx +22 -0
  232. package/src/components/SwipeStack/__tests__/SwipeStack.test.tsx +169 -0
  233. package/src/components/SystemBanner/SystemBanner.module.css +10 -0
  234. package/src/components/Table/Table.module.css +2 -2
  235. package/src/components/Tabs/Tabs.module.css +1 -1
  236. package/src/components/TagInput/TagInput.module.css +2 -2
  237. package/src/components/Textarea/Textarea.module.css +9 -1
  238. package/src/components/Textarea/Textarea.tsx +28 -5
  239. package/src/components/Textarea/__tests__/Textarea.test.tsx +23 -1
  240. package/src/components/ThreeColumnLayout/ThreeColumnLayout.module.css +35 -0
  241. package/src/components/ThreeColumnLayout/ThreeColumnLayout.stories.tsx +152 -0
  242. package/src/components/ThreeColumnLayout/ThreeColumnLayout.tsx +87 -0
  243. package/src/components/ThreeColumnLayout/__tests__/ThreeColumnLayout.test.tsx +78 -0
  244. package/src/components/Toast/Toast.module.css +62 -3
  245. package/src/components/Toast/Toast.stories.tsx +1 -1
  246. package/src/components/Tooltip/Tooltip.module.css +1 -1
  247. package/src/components/UserMenu/UserMenu.module.css +90 -0
  248. package/src/components/UserMenu/UserMenu.stories.tsx +49 -0
  249. package/src/components/UserMenu/UserMenu.tsx +132 -0
  250. package/src/components/UserMenu/__tests__/UserMenu.test.tsx +77 -0
  251. package/src/hooks/__tests__/usePublishShellChromeHeight.test.tsx +118 -0
  252. package/src/hooks/__tests__/useReorderSensors.test.ts +26 -0
  253. package/src/hooks/__tests__/useSnapCarousel.test.tsx +82 -0
  254. package/src/hooks/__tests__/useSwipeStack.test.tsx +155 -0
  255. package/src/hooks/index.ts +9 -0
  256. package/src/hooks/usePublishShellChromeHeight.ts +96 -0
  257. package/src/hooks/useReorderSensors.ts +88 -0
  258. package/src/hooks/useSnapCarousel.ts +211 -6
  259. package/src/hooks/useSwipeStack.ts +235 -0
  260. package/src/index.ts +61 -1
  261. package/src/overlay-shell.ts +65 -0
  262. package/src/stories/DesignSystemOverview.stories.tsx +617 -11
  263. package/src/stories/GripIcon.stories.tsx +29 -0
  264. package/src/stories/LayoutSystem.stories.tsx +639 -0
  265. package/src/stories/MenuEntries.stories.tsx +86 -0
  266. package/src/stories/NavList.stories.tsx +147 -0
  267. package/src/stories/PageContainer.stories.tsx +52 -7
  268. package/src/stories/PageHeader.stories.tsx +54 -1
  269. package/src/stories/StepStrip.stories.tsx +76 -0
  270. package/src/stories/SwipableMobileCarousel.stories.tsx +58 -0
  271. package/src/stories/SwipeStack.stories.tsx +200 -0
  272. package/src/stories/Textarea.stories.tsx +12 -0
  273. package/src/stories/TokenGallery.stories.tsx +50 -0
  274. package/src/tokens/primitives.css +11 -0
  275. package/src/tokens/semantic.css +32 -0
  276. package/src/tokens/theme-presets.css +33 -0
  277. package/src/tokens/theme-presets.ts +72 -0
@@ -36,6 +36,7 @@ describe('useSnapCarousel', () => {
36
36
  const onSettle = vi.fn();
37
37
  const { getByTestId } = render(<Harness itemCount={3} onSettle={onSettle} debounceMs={120} />);
38
38
  const el = getByTestId('scroller');
39
+ fireEvent.touchStart(el);
39
40
  setLayout(el, 300, 300); // scrollLeft 300 / width 300 -> index 1
40
41
  fireEvent.scroll(el);
41
42
  expect(onSettle).not.toHaveBeenCalled();
@@ -47,6 +48,7 @@ describe('useSnapCarousel', () => {
47
48
  const onSettle = vi.fn();
48
49
  const { getByTestId } = render(<Harness itemCount={3} onSettle={onSettle} debounceMs={120} />);
49
50
  const el = getByTestId('scroller');
51
+ fireEvent.touchStart(el);
50
52
  setLayout(el, 300, 0);
51
53
  fireEvent.scroll(el);
52
54
  act(() => void vi.advanceTimersByTime(60));
@@ -61,9 +63,11 @@ describe('useSnapCarousel', () => {
61
63
  const onSettle = vi.fn();
62
64
  const { getByTestId } = render(<Harness itemCount={3} onSettle={onSettle} debounceMs={120} />);
63
65
  const el = getByTestId('scroller');
66
+ fireEvent.touchStart(el);
64
67
  setLayout(el, 300, 300);
65
68
  fireEvent.scroll(el);
66
69
  act(() => void vi.advanceTimersByTime(120));
70
+ fireEvent.touchStart(el);
67
71
  fireEvent.scroll(el);
68
72
  act(() => void vi.advanceTimersByTime(120));
69
73
  expect(onSettle).toHaveBeenCalledOnce();
@@ -73,12 +77,90 @@ describe('useSnapCarousel', () => {
73
77
  const onSettle = vi.fn();
74
78
  const { getByTestId } = render(<Harness itemCount={2} onSettle={onSettle} debounceMs={120} />);
75
79
  const el = getByTestId('scroller');
80
+ fireEvent.touchStart(el);
76
81
  setLayout(el, 300, 900); // rounds to index 3, clamps to itemCount - 1 = 1
77
82
  fireEvent.scroll(el);
78
83
  act(() => void vi.advanceTimersByTime(120));
79
84
  expect(onSettle).toHaveBeenCalledWith(1);
80
85
  });
81
86
 
87
+ it('ignores a scroll event with no active gesture (drift, not a settle)', () => {
88
+ // Regression test: reproduced live via a user screen recording — a
89
+ // slide's content changing size while still loading can nudge
90
+ // scrollLeft enough to round to a different index. Previously any such
91
+ // stray `scroll` event was enough to fire a second, contradicting
92
+ // onSettle, silently "auto-swiping" the carousel back to the wrong
93
+ // slide with no further touch input.
94
+ const onSettle = vi.fn();
95
+ const { getByTestId } = render(<Harness itemCount={10} onSettle={onSettle} debounceMs={120} />);
96
+ const el = getByTestId('scroller');
97
+ fireEvent.touchStart(el);
98
+ setLayout(el, 300, 1800); // settles cleanly on index 6
99
+ fireEvent.scroll(el);
100
+ act(() => void vi.advanceTimersByTime(120));
101
+ expect(onSettle).toHaveBeenCalledExactlyOnceWith(6);
102
+
103
+ // Well outside the grace window and no further touch/wheel/pointer
104
+ // activity — this scroll event is drift, not a resettle.
105
+ act(() => void vi.advanceTimersByTime(1000));
106
+ setLayout(el, 300, 1620); // would round to index 5
107
+ fireEvent.scroll(el);
108
+ act(() => void vi.advanceTimersByTime(120));
109
+ expect(onSettle).toHaveBeenCalledExactlyOnceWith(6);
110
+ });
111
+
112
+ it('still settles on long-running momentum/snap-correction scroll with no further touch input', () => {
113
+ // Regression test for a bug in the FIRST version of the drift fix above:
114
+ // gating on "was there a touch/wheel/pointer event within a fixed
115
+ // debounceMs * 2 slack of settle-check time" dropped real settles once
116
+ // native momentum/snap-correction ran longer than that slack — which it
117
+ // routinely does for a fast flick. That left activeIndex stuck on the
118
+ // old slide while the container had already scrolled to the new one,
119
+ // and since the mount window is keyed off activeIndex, the slide the
120
+ // user was actually looking at could get unmounted — a blank carousel,
121
+ // worse than the bug being fixed. The chain stays trusted for as long as
122
+ // scroll events keep arriving without a debounceMs-or-longer gap,
123
+ // regardless of how long that run lasts or how few touch events back it.
124
+ const onSettle = vi.fn();
125
+ const { getByTestId } = render(<Harness itemCount={10} onSettle={onSettle} debounceMs={120} />);
126
+ const el = getByTestId('scroller');
127
+ fireEvent.touchStart(el);
128
+ setLayout(el, 300, 1700);
129
+ fireEvent.scroll(el); // starts the run — trusted (touchstart just happened)
130
+ fireEvent.touchEnd(el);
131
+ // Momentum keeps producing scroll events, each within debounceMs of the
132
+ // last (so the run never goes quiet), for far longer than any fixed
133
+ // slack window would tolerate — no further touch input at all.
134
+ for (const scrollLeft of [1720, 1740, 1760, 1780, 1800]) {
135
+ act(() => void vi.advanceTimersByTime(80));
136
+ setLayout(el, 300, scrollLeft);
137
+ fireEvent.scroll(el);
138
+ }
139
+ act(() => void vi.advanceTimersByTime(120));
140
+ expect(onSettle).toHaveBeenCalledExactlyOnceWith(6);
141
+ });
142
+
143
+ it('settles correctly on two independent real swipes fired back-to-back', () => {
144
+ // Regression test: two genuine swipes with essentially no gap between
145
+ // them (an impatient fast double-swipe) must not corrupt or drop either
146
+ // settle — each is its own trusted run.
147
+ const onSettle = vi.fn();
148
+ const { getByTestId } = render(<Harness itemCount={10} onSettle={onSettle} debounceMs={120} />);
149
+ const el = getByTestId('scroller');
150
+ fireEvent.touchStart(el);
151
+ setLayout(el, 300, 300);
152
+ fireEvent.scroll(el);
153
+ act(() => void vi.advanceTimersByTime(120));
154
+ expect(onSettle).toHaveBeenCalledExactlyOnceWith(1);
155
+
156
+ fireEvent.touchStart(el);
157
+ setLayout(el, 300, 600);
158
+ fireEvent.scroll(el);
159
+ act(() => void vi.advanceTimersByTime(120));
160
+ expect(onSettle).toHaveBeenCalledTimes(2);
161
+ expect(onSettle).toHaveBeenLastCalledWith(2);
162
+ });
163
+
82
164
  it('scrollToIndex scrolls the container to the target slide', () => {
83
165
  let scrollToIndex: ((index: number) => void) | undefined;
84
166
  const { getByTestId } = render(
@@ -0,0 +1,155 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { cleanup, fireEvent, render } from '@testing-library/react';
4
+ import { useSwipeStack, type SwipeDirection, type UseSwipeStackOptions } from '../useSwipeStack';
5
+
6
+ // jsdom doesn't implement pointer capture — stub it as a no-op so
7
+ // `e.currentTarget.setPointerCapture(...)` doesn't throw.
8
+ beforeEach(() => {
9
+ if (!HTMLElement.prototype.setPointerCapture) {
10
+ HTMLElement.prototype.setPointerCapture = vi.fn();
11
+ }
12
+ });
13
+
14
+ afterEach(cleanup);
15
+
16
+ function Harness(props: UseSwipeStackOptions & { triggerDirection?: SwipeDirection }) {
17
+ const { triggerDirection, ...options } = props;
18
+ const { cardRef, wrapRef, handlers, touchAction, triggerCommit } = useSwipeStack(options);
19
+ return (
20
+ <div data-testid="wrap" ref={wrapRef}>
21
+ <div data-testid="card" ref={cardRef} data-touch-action={touchAction} {...handlers}>
22
+ card
23
+ </div>
24
+ {triggerDirection && (
25
+ <button data-testid="trigger" onClick={() => triggerCommit(triggerDirection)}>
26
+ trigger
27
+ </button>
28
+ )}
29
+ </div>
30
+ );
31
+ }
32
+
33
+ function pointer(x: number, y: number) {
34
+ return { clientX: x, clientY: y, pointerId: 1 };
35
+ }
36
+
37
+ function drag(el: HTMLElement, dx: number, dy: number) {
38
+ fireEvent.pointerDown(el, pointer(0, 0));
39
+ fireEvent.pointerMove(el, pointer(dx, dy));
40
+ fireEvent.pointerUp(el, pointer(dx, dy));
41
+ }
42
+
43
+ describe('useSwipeStack', () => {
44
+ it('commits when a drag crosses threshold in a live direction', () => {
45
+ const onCommit = vi.fn();
46
+ const { getByTestId } = render(
47
+ <Harness directions={['up', 'down', 'left', 'right']} threshold={100} onCommit={onCommit} />,
48
+ );
49
+ drag(getByTestId('card'), 0, -120);
50
+ expect(onCommit).toHaveBeenCalledWith('up');
51
+ expect(onCommit).toHaveBeenCalledOnce();
52
+ });
53
+
54
+ it('springs back without committing when under threshold', () => {
55
+ const onCommit = vi.fn();
56
+ const { getByTestId } = render(
57
+ <Harness directions={['up', 'down', 'left', 'right']} threshold={100} onCommit={onCommit} />,
58
+ );
59
+ drag(getByTestId('card'), 0, -40);
60
+ expect(onCommit).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('does not commit within the deadzone even if threshold is set very low', () => {
64
+ const onCommit = vi.fn();
65
+ const { getByTestId } = render(
66
+ <Harness directions={['up']} threshold={2} onCommit={onCommit} />,
67
+ );
68
+ drag(getByTestId('card'), 0, -5);
69
+ expect(onCommit).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it('treats a non-configured direction as a wall — no commit even past what would be threshold distance', () => {
73
+ const onCommit = vi.fn();
74
+ const { getByTestId } = render(
75
+ <Harness directions={['up', 'down']} threshold={100} onCommit={onCommit} />,
76
+ );
77
+ drag(getByTestId('card'), 150, 0);
78
+ expect(onCommit).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it('does nothing when disabled', () => {
82
+ const onCommit = vi.fn();
83
+ const { getByTestId } = render(
84
+ <Harness
85
+ directions={['up', 'down', 'left', 'right']}
86
+ threshold={100}
87
+ onCommit={onCommit}
88
+ disabled
89
+ />,
90
+ );
91
+ drag(getByTestId('card'), 0, -150);
92
+ expect(onCommit).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('ignores pointerup without a preceding pointerdown', () => {
96
+ const onCommit = vi.fn();
97
+ const { getByTestId } = render(
98
+ <Harness directions={['up', 'down', 'left', 'right']} threshold={100} onCommit={onCommit} />,
99
+ );
100
+ fireEvent.pointerUp(getByTestId('card'), pointer(0, -150));
101
+ expect(onCommit).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it('triggerCommit commits a live direction without a drag', () => {
105
+ const onCommit = vi.fn();
106
+ const { getByTestId } = render(
107
+ <Harness directions={['up']} onCommit={onCommit} triggerDirection="up" />,
108
+ );
109
+ fireEvent.click(getByTestId('trigger'));
110
+ expect(onCommit).toHaveBeenCalledWith('up');
111
+ expect(onCommit).toHaveBeenCalledOnce();
112
+ });
113
+
114
+ it('triggerCommit no-ops for a direction that is not live', () => {
115
+ const onCommit = vi.fn();
116
+ const { getByTestId } = render(
117
+ <Harness directions={['up']} onCommit={onCommit} triggerDirection="down" />,
118
+ );
119
+ fireEvent.click(getByTestId('trigger'));
120
+ expect(onCommit).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it('triggerCommit does nothing when disabled', () => {
124
+ const onCommit = vi.fn();
125
+ const { getByTestId } = render(
126
+ <Harness directions={['up']} onCommit={onCommit} triggerDirection="up" disabled />,
127
+ );
128
+ fireEvent.click(getByTestId('trigger'));
129
+ expect(onCommit).not.toHaveBeenCalled();
130
+ });
131
+
132
+ describe('touchAction', () => {
133
+ it('is none when both axes have a live direction', () => {
134
+ const { getByTestId } = render(
135
+ <Harness directions={['left', 'right', 'up', 'down']} onCommit={vi.fn()} />,
136
+ );
137
+ expect(getByTestId('card').dataset.touchAction).toBe('none');
138
+ });
139
+
140
+ it('is pan-y when only horizontal directions are live', () => {
141
+ const { getByTestId } = render(<Harness directions={['left', 'right']} onCommit={vi.fn()} />);
142
+ expect(getByTestId('card').dataset.touchAction).toBe('pan-y');
143
+ });
144
+
145
+ it('is pan-x when only vertical directions are live', () => {
146
+ const { getByTestId } = render(<Harness directions={['up', 'down']} onCommit={vi.fn()} />);
147
+ expect(getByTestId('card').dataset.touchAction).toBe('pan-x');
148
+ });
149
+
150
+ it('is auto when no directions are live', () => {
151
+ const { getByTestId } = render(<Harness directions={[]} onCommit={vi.fn()} />);
152
+ expect(getByTestId('card').dataset.touchAction).toBe('auto');
153
+ });
154
+ });
155
+ });
@@ -22,3 +22,12 @@ export type {
22
22
  UseCarouselRouteSyncOptions,
23
23
  UseCarouselRouteSyncResult,
24
24
  } from './useCarouselRouteSync';
25
+ export { useReorderSensors, shouldHandleDndEvent } from './useReorderSensors';
26
+ export type { UseReorderSensorsOptions } from './useReorderSensors';
27
+ export { useSwipeStack, SWIPE_STACK_TRANSITION_MS } from './useSwipeStack';
28
+ export type {
29
+ SwipeDirection,
30
+ UseSwipeStackOptions,
31
+ UseSwipeStackHandlers,
32
+ UseSwipeStackResult,
33
+ } from './useSwipeStack';
@@ -0,0 +1,96 @@
1
+ 'use client';
2
+
3
+ import { useLayoutEffect } from 'react';
4
+ import type { RefObject } from 'react';
5
+
6
+ /**
7
+ * Internal implementation detail of `MobileHeader`/`MobileFooter` — not part
8
+ * of the package's public surface (not re-exported from `hooks/index.ts`).
9
+ *
10
+ * Measures `ref`'s own rendered height and publishes it as an inline
11
+ * `var(--sv-shell-header-height|--sv-shell-footer-height)` override on
12
+ * `#sv-app-shell` (the runtime shell's stable root id, `runtime/app/
13
+ * (platform)/layout.tsx`), removing it on unmount. This is what makes a
14
+ * *self-rendered* `MobileHeader`/`MobileFooter` — the sanctioned pattern for
15
+ * a plugin that opts out of the platform's own chrome via
16
+ * `shellConfig.mobileHeader`/`mobileFooter: false` (RFC 0075/0088) — work
17
+ * correctly with `Sheet`/`Drawer`/`Dialog`, which all size themselves against
18
+ * these same two variables so they stop above/below the chrome instead of
19
+ * sliding underneath it.
20
+ *
21
+ * Before this existed, only the *platform's own* header/footer had its
22
+ * height accounted for (a fixed CSS `calc()` in `shell.module.css`, driven by
23
+ * `data-mobile-header-hidden`/`data-mobile-footer-hidden`, which collapses
24
+ * the variable to `0px` whenever a plugin hides that chrome) — a plugin
25
+ * self-rendering its own via this same component instead had no path to tell
26
+ * the shell what its real height was, and silently inherited that `0px`.
27
+ * `Sheet`/`Drawer` then extended full-height, and since the header/footer's
28
+ * own `z-index` (101) beats an overlay's (100), the chrome visibly covered
29
+ * the overlay's last ~60px. First found and fixed ad hoc in `sovereign-tasks`
30
+ * (measuring in the plugin itself, `getBoundingClientRect()` in a
31
+ * `useLayoutEffect`); moved here so every consumer of these two components —
32
+ * present and future, in this repo or externally maintained — gets it for
33
+ * free, without having to know the mechanism exists. See
34
+ * `docs/architecture-rules.md` for the full rule.
35
+ *
36
+ * Deliberately **not** `ResizeObserver`: in live testing (both this repo's
37
+ * own Chromium-based browser-preview tooling and a real WebKit iOS Simulator
38
+ * session) a freshly created `ResizeObserver` never fired its callback even
39
+ * once for an already-rendered, stably-sized, non-zero element — cause not
40
+ * fully root-caused. A synchronous `getBoundingClientRect()` read in
41
+ * `useLayoutEffect`, re-run on `window`'s `resize` event, worked immediately
42
+ * and reliably in both environments instead.
43
+ *
44
+ * No-ops safely wherever `#sv-app-shell` doesn't exist (Storybook, a unit
45
+ * test, or any host other than the platform's own runtime shell) — every CSS
46
+ * consumer reads these variables with a `60px` fallback, so an absent
47
+ * override is harmless.
48
+ *
49
+ * For `--sv-shell-header-height` specifically, this also mirrors the same
50
+ * measured height onto `--sv-dialog-inset-top` on `:root` (`document.
51
+ * documentElement`, not `#sv-app-shell`) — `Dialog`'s mobile `.scrim` is
52
+ * `position: fixed` and reads that variable for its own `top` offset, and
53
+ * per `globals.css`'s own comment, iOS Safari has a documented quirk where a
54
+ * `position: fixed` element inherits custom properties from `:root` rather
55
+ * than from its DOM ancestors, so a value set only on `#sv-app-shell` (an
56
+ * ancestor, not `:root`) silently never reaches it there. The platform's own
57
+ * header already keeps `--sv-dialog-inset-top` correct this way, via
58
+ * `ClientShell.tsx`'s own `syncViewport()` — but that function is part of
59
+ * `(platform)/layout.tsx` and never runs for a `shell: minimal` plugin, so a
60
+ * self-rendered `MobileHeader` (this hook's entire reason to exist) had no
61
+ * equivalent, and `--sv-dialog-inset-top` silently kept `globals.css`'s
62
+ * hardcoded platform-header-height fallback (60px) regardless of this
63
+ * header's real height. Found live in `sovereign-plugin-kanban`: its
64
+ * self-rendered header measures 69px, so its card-detail `Dialog` opened
65
+ * with a real, reproducible ~9px misalignment against that header's own
66
+ * bottom edge — confirmed via `getBoundingClientRect()` on both elements,
67
+ * not just visually. */
68
+ export function usePublishShellChromeHeight(
69
+ ref: RefObject<HTMLElement | null>,
70
+ cssVariable: '--sv-shell-header-height' | '--sv-shell-footer-height',
71
+ ) {
72
+ useLayoutEffect(() => {
73
+ const el = ref.current;
74
+ if (!el) return;
75
+
76
+ function publish() {
77
+ const shell = document.getElementById('sv-app-shell');
78
+ if (!shell || !el) return;
79
+ const height = `${el.getBoundingClientRect().height}px`;
80
+ shell.style.setProperty(cssVariable, height);
81
+ if (cssVariable === '--sv-shell-header-height') {
82
+ document.documentElement.style.setProperty('--sv-dialog-inset-top', height);
83
+ }
84
+ }
85
+
86
+ publish();
87
+ window.addEventListener('resize', publish);
88
+ return () => {
89
+ window.removeEventListener('resize', publish);
90
+ document.getElementById('sv-app-shell')?.style.removeProperty(cssVariable);
91
+ if (cssVariable === '--sv-shell-header-height') {
92
+ document.documentElement.style.removeProperty('--sv-dialog-inset-top');
93
+ }
94
+ };
95
+ }, [ref, cssVariable]);
96
+ }
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+
3
+ import {
4
+ KeyboardSensor,
5
+ MouseSensor as LibMouseSensor,
6
+ TouchSensor as LibTouchSensor,
7
+ useSensor,
8
+ useSensors,
9
+ } from '@dnd-kit/core';
10
+ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
11
+ import type { MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent } from 'react';
12
+
13
+ /** Plain pointer drag (desktop, handle-initiated). */
14
+ const DEFAULT_MOUSE_ACTIVATION_DISTANCE_PX = 8;
15
+
16
+ /** Long-press-to-lift on touch. `delay` is how long a still hold takes before
17
+ * the row lifts; `tolerance` is how far the finger may drift during that
18
+ * hold before it's treated as a scroll instead — a finger that moves further
19
+ * than this within the delay window cancels activation and the native
20
+ * vertical scroll wins. */
21
+ const DEFAULT_TOUCH_ACTIVATION_DELAY_MS = 300;
22
+ const DEFAULT_TOUCH_ACTIVATION_TOLERANCE_PX = 8;
23
+
24
+ /**
25
+ * True when a drag should be allowed to start from `target`. Refused when
26
+ * `target` sits inside an element marked `data-no-dnd` — a nested control
27
+ * (e.g. a visibility toggle) opts out so tapping it fires its own handler
28
+ * instead of lifting the row. Exported standalone so it's unit-testable
29
+ * without spinning up dnd-kit.
30
+ */
31
+ export function shouldHandleDndEvent(target: EventTarget | null): boolean {
32
+ if (!(target instanceof Element)) return true;
33
+ return target.closest('[data-no-dnd]') === null;
34
+ }
35
+
36
+ export interface UseReorderSensorsOptions {
37
+ mouseActivationDistancePx?: number;
38
+ touchActivationDelayMs?: number;
39
+ touchActivationTolerancePx?: number;
40
+ }
41
+
42
+ // dnd-kit's documented pattern for scoping sensor activation to specific
43
+ // elements: subclass the built-in sensor and replace its static `activators`
44
+ // with a handler that inspects the originating event's target before
45
+ // deferring to the base activation logic (distance/delay/tolerance), which
46
+ // dnd-kit re-checks internally regardless of what this returns.
47
+ class MouseSensor extends LibMouseSensor {
48
+ static override activators = [
49
+ {
50
+ eventName: 'onMouseDown' as const,
51
+ handler: ({ nativeEvent: event }: ReactMouseEvent) => shouldHandleDndEvent(event.target),
52
+ },
53
+ ];
54
+ }
55
+
56
+ class TouchSensor extends LibTouchSensor {
57
+ static override activators = [
58
+ {
59
+ eventName: 'onTouchStart' as const,
60
+ handler: ({ nativeEvent: event }: ReactTouchEvent) => shouldHandleDndEvent(event.target),
61
+ },
62
+ ];
63
+ }
64
+
65
+ /**
66
+ * Sensor set for a `data-no-dnd`-aware drag-reorder list — MouseSensor for
67
+ * desktop's handle-initiated drag, TouchSensor for mobile's long-press lift
68
+ * (fixes reordering being unusable on iOS PWA/Safari, which never implements
69
+ * the native HTML5 Drag-and-Drop API for touch input), KeyboardSensor
70
+ * unchanged. A bare call keeps the historical 8px/300ms/8px defaults.
71
+ */
72
+ export function useReorderSensors(options: UseReorderSensorsOptions = {}) {
73
+ const {
74
+ mouseActivationDistancePx = DEFAULT_MOUSE_ACTIVATION_DISTANCE_PX,
75
+ touchActivationDelayMs = DEFAULT_TOUCH_ACTIVATION_DELAY_MS,
76
+ touchActivationTolerancePx = DEFAULT_TOUCH_ACTIVATION_TOLERANCE_PX,
77
+ } = options;
78
+ return useSensors(
79
+ useSensor(MouseSensor, { activationConstraint: { distance: mouseActivationDistancePx } }),
80
+ useSensor(TouchSensor, {
81
+ activationConstraint: {
82
+ delay: touchActivationDelayMs,
83
+ tolerance: touchActivationTolerancePx,
84
+ },
85
+ }),
86
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
87
+ );
88
+ }