@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
@@ -131,17 +131,18 @@ export function SwipableMobileCarousel({
131
131
  const count = slides.length;
132
132
  const clampedActiveIndex = count === 0 ? 0 : Math.max(0, Math.min(count - 1, activeIndex));
133
133
 
134
- const { scrollRef, scrollToIndex } = useSnapCarousel({
135
- itemCount: count,
136
- onSettle,
137
- debounceMs: settleDebounceMs,
138
- });
139
-
140
134
  // Initial scroll position, once — subsequent activeIndex changes come
141
135
  // either from the user's own swipe (already reflected in DOM scroll
142
136
  // position; re-syncing to the same index below is a harmless no-op) or an
143
137
  // external navigation, which the effect below scrolls to explicitly.
144
138
  const initialActiveIndexRef = useRef(clampedActiveIndex);
139
+
140
+ const { scrollRef, scrollToIndex, liveIndex } = useSnapCarousel({
141
+ itemCount: count,
142
+ onSettle,
143
+ debounceMs: settleDebounceMs,
144
+ initialIndex: initialActiveIndexRef.current,
145
+ });
145
146
  useLayoutEffect(() => {
146
147
  scrollToIndex(initialActiveIndexRef.current, 'instant');
147
148
  // Runs once on mount only, matching both existing plugins' identical pattern.
@@ -199,7 +200,6 @@ export function SwipableMobileCarousel({
199
200
  renderIndicator({ count, activeIndex: clampedActiveIndex, labels, onJump: handleJump })
200
201
  ) : (
201
202
  <SwipableMobileCarouselDots
202
- className={styles.dots}
203
203
  count={count}
204
204
  activeIndex={clampedActiveIndex}
205
205
  labels={labels}
@@ -214,14 +214,30 @@ export function SwipableMobileCarousel({
214
214
  {slides.map((slide, i) => (
215
215
  <div className={styles.slide} key={slide.key}>
216
216
  <CarouselSlideMountContext.Provider
217
- value={{ isMounted: Math.abs(i - clampedActiveIndex) <= prefetchDistance }}
217
+ value={{
218
+ // Union of the settled index and the live (real-time,
219
+ // pre-settle) scroll position — see useSnapCarousel's own
220
+ // liveIndex doc comment. A single fast flick can carry
221
+ // native scroll-snap momentum past an intermediate slide to
222
+ // one two-or-more away before settle-detection's debounce
223
+ // window ever reports it; without also checking liveIndex,
224
+ // the slide the container had already visually scrolled to
225
+ // stays unmounted (rendering nothing at all, not even a
226
+ // loading skeleton) for that entire window.
227
+ isMounted:
228
+ Math.abs(i - clampedActiveIndex) <= prefetchDistance ||
229
+ Math.abs(i - liveIndex) <= prefetchDistance,
230
+ }}
218
231
  >
219
232
  {slide.element}
220
233
  </CarouselSlideMountContext.Provider>
221
234
  </div>
222
235
  ))}
223
236
  </div>
224
- {indicator}
237
+ {/* Positioning (overlay the bottom of .wrap) lives here, applied around
238
+ whatever renderIndicator returns — see .indicatorSlot's own doc
239
+ comment for why this can't be delegated to the indicator itself. */}
240
+ {indicator && <div className={styles.indicatorSlot}>{indicator}</div>}
225
241
  </div>
226
242
  );
227
243
  }
@@ -79,6 +79,7 @@ describe('SwipableMobileCarousel', () => {
79
79
  const onSettle = vi.fn();
80
80
  render(<FiveSlides activeIndex={0} onSettle={onSettle} />);
81
81
  const scroller = screen.getByRole('region', { name: 'Test slides' });
82
+ fireEvent.touchStart(scroller);
82
83
  setLayout(scroller, 300, 300); // scrollLeft 300 / width 300 -> index 1
83
84
  fireEvent.scroll(scroller);
84
85
  act(() => void vi.advanceTimersByTime(120));
@@ -96,6 +97,43 @@ describe('SwipableMobileCarousel', () => {
96
97
  expect(onSettle).toHaveBeenCalledWith(3);
97
98
  });
98
99
 
100
+ it('positions both the default and a custom renderIndicator inside the same overlay slot', () => {
101
+ // Regression test: the overlay-positioning class used to be applied
102
+ // directly to the default SwipableMobileCarouselDots instance, so any
103
+ // caller-supplied renderIndicator rendered as a plain static element
104
+ // instead — pushed out of view below the full-height scroller. The
105
+ // carousel must own positioning for whatever renderIndicator returns.
106
+ const { unmount } = render(<FiveSlides activeIndex={0} onSettle={() => {}} />);
107
+ const defaultTablist = screen.getByRole('tablist');
108
+ expect(defaultTablist.parentElement?.className).toMatch(/indicatorSlot/);
109
+ unmount();
110
+
111
+ render(
112
+ <SwipableMobileCarousel
113
+ activeIndex={0}
114
+ onSettle={() => {}}
115
+ aria-label="Test slides"
116
+ renderIndicator={({ count, activeIndex, onJump }) => (
117
+ <div role="tablist" aria-label="Custom">
118
+ {Array.from({ length: count }, (_, i) => (
119
+ <button key={i} type="button" role="tab" onClick={() => onJump(i)}>
120
+ {i === activeIndex ? 'active' : 'inactive'}
121
+ </button>
122
+ ))}
123
+ </div>
124
+ )}
125
+ >
126
+ {KEYS.map((key) => (
127
+ <SwipableMobileCarouselSlide key={key} slideKey={key} label={key}>
128
+ {`slide-${key}`}
129
+ </SwipableMobileCarouselSlide>
130
+ ))}
131
+ </SwipableMobileCarousel>,
132
+ );
133
+ const customTablist = screen.getByRole('tablist', { name: 'Custom' });
134
+ expect(customTablist.parentElement?.className).toMatch(/indicatorSlot/);
135
+ });
136
+
99
137
  it('re-snaps and reports the new index when the active slide moves position (reorder-jump fix)', () => {
100
138
  function Reorderable({ order, onSettle }: { order: string[]; onSettle: (i: number) => void }) {
101
139
  return (
@@ -121,6 +159,49 @@ describe('SwipableMobileCarousel', () => {
121
159
  expect(spy).toHaveBeenCalledWith({ left: 0, behavior: 'instant' });
122
160
  });
123
161
 
162
+ it('mounts a slide reached by a fast multi-slide flick before settle-detection reports it', () => {
163
+ // Regression test, found via a user screen recording: a fast flick can
164
+ // carry native scroll-snap momentum straight past an intermediate slide
165
+ // to one two-or-more away in a single continuous gesture. Settle-
166
+ // detection deliberately still waits out the full debounce window before
167
+ // calling onSettle (to distinguish a real settle from drift — see
168
+ // useSnapCarousel's chainTrustedRef comment), but the mount window used
169
+ // to be keyed *only* off that settled index — so the slide the container
170
+ // had already scrolled to, visually, sat completely unmounted (not even
171
+ // a loading skeleton, per SwipableMobileCarouselSlide's `return null`)
172
+ // for the entire debounce window. liveIndex closes that gap.
173
+ const onSettle = vi.fn();
174
+ const KEYS8 = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
175
+ function EightSlides({ onSettle }: { onSettle: (i: number) => void }) {
176
+ return (
177
+ <SwipableMobileCarousel activeIndex={0} onSettle={onSettle} aria-label="Test slides">
178
+ {KEYS8.map((key) => (
179
+ <SwipableMobileCarouselSlide key={key} slideKey={key} label={key}>
180
+ <SwipableMobileCarouselSlideHeader>{`header-${key}`}</SwipableMobileCarouselSlideHeader>
181
+ </SwipableMobileCarouselSlide>
182
+ ))}
183
+ </SwipableMobileCarousel>
184
+ );
185
+ }
186
+ render(<EightSlides onSettle={onSettle} />);
187
+ const scroller = screen.getByRole('region', { name: 'Test slides' });
188
+ // Slide 'e' (index 4) is well outside the default prefetchDistance=1
189
+ // window around the settled index 0.
190
+ expect(screen.queryByText('header-e')).toBeNull();
191
+
192
+ fireEvent.touchStart(scroller);
193
+ setLayout(scroller, 300, 1200); // scrollLeft 1200 / width 300 -> index 4
194
+ fireEvent.scroll(scroller);
195
+ // Well short of the 120ms settle debounce — onSettle must not have fired
196
+ // yet, but the rAF-throttled liveIndex update has had time to run.
197
+ act(() => void vi.advanceTimersByTime(20));
198
+ expect(onSettle).not.toHaveBeenCalled();
199
+ expect(screen.getByText('header-e')).toBeDefined();
200
+
201
+ act(() => void vi.advanceTimersByTime(120));
202
+ expect(onSettle).toHaveBeenCalledWith(4);
203
+ });
204
+
124
205
  it('does not re-snap when slide order is unchanged', () => {
125
206
  function Reorderable({ onSettle }: { onSettle: (i: number) => void }) {
126
207
  return <FiveSlides activeIndex={2} onSettle={onSettle} />;
@@ -5,6 +5,21 @@
5
5
  gap: var(--sv-space-2);
6
6
  }
7
7
 
8
+ /* Opt-in — see the `density` prop doc comment. Shrinks both the gap and each
9
+ dot's own hit target (20px -> 16px) — for a carousel with enough slides
10
+ that even the space-1 gap alone still read as too wide. The visible 6px
11
+ dot is unchanged; only the surrounding tap-target box shrinks, via the
12
+ descendant override below rather than a second dot variant, so this stays
13
+ a pure CSS opt-in with no JS/prop plumbing beyond `density` itself. */
14
+ .dotsCompact {
15
+ gap: var(--sv-space-1);
16
+ }
17
+
18
+ .dotsCompact .dot {
19
+ width: 16px;
20
+ height: 16px;
21
+ }
22
+
8
23
  .dot {
9
24
  /* A 6px visible dot is too small a hit target on its own — the button's
10
25
  real box is larger (20px) with the dot itself painted by ::before and
@@ -12,6 +12,14 @@ export interface SwipableMobileCarouselDotsProps {
12
12
  /** Labels the whole group — required, no generic fallback (mirrors
13
13
  * SwipableMobileCarousel's own required aria-label). */
14
14
  'aria-label': string;
15
+ /** `'compact'` halves the gap between dots (`--sv-space-2` → `--sv-space-1`)
16
+ * and shrinks each dot's own hit target (20px → 16px) — for a carousel
17
+ * with enough slides that even the reduced gap alone still reads as too
18
+ * wide on a narrow viewport (e.g. a many-list mobile app). The visible
19
+ * 6px dot itself is unchanged; only its surrounding tap-target box
20
+ * shrinks. Defaults to `'default'` — every existing consumer's spacing is
21
+ * unaffected unless it opts in. */
22
+ density?: 'default' | 'compact';
15
23
  className?: string;
16
24
  }
17
25
 
@@ -34,13 +42,16 @@ export function SwipableMobileCarouselDots({
34
42
  onJump,
35
43
  labels,
36
44
  'aria-label': ariaLabel,
45
+ density = 'default',
37
46
  className,
38
47
  }: SwipableMobileCarouselDotsProps) {
39
48
  return (
40
49
  <div
41
50
  role="tablist"
42
51
  aria-label={ariaLabel}
43
- className={[styles.dots, className].filter(Boolean).join(' ')}
52
+ className={[styles.dots, density === 'compact' ? styles.dotsCompact : '', className]
53
+ .filter(Boolean)
54
+ .join(' ')}
44
55
  >
45
56
  {Array.from({ length: count }, (_, i) => {
46
57
  const active = i === activeIndex;
@@ -0,0 +1,101 @@
1
+ .wrap {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--sv-space-3);
5
+ height: 100%;
6
+ width: 100%;
7
+ }
8
+
9
+ .stack {
10
+ position: relative;
11
+ flex: 1 1 auto;
12
+ min-height: 0;
13
+ }
14
+
15
+ .peek {
16
+ position: absolute;
17
+ inset: 0;
18
+ transform: scale(0.94) translateY(var(--sv-space-3));
19
+ opacity: 0.6;
20
+ pointer-events: none;
21
+ }
22
+
23
+ .cardSlot {
24
+ position: absolute;
25
+ inset: 0;
26
+ }
27
+
28
+ /* Styled directly rather than wrapping the Card component: Card doesn't
29
+ forward a ref, and this element must be the exact node useSwipeStack
30
+ translates/rotates as one unit — a Card wrapping an inner dragged div
31
+ would leave its own border/shadow visually static while only the inner
32
+ div moved. */
33
+ .card {
34
+ position: relative;
35
+ height: 100%;
36
+ width: 100%;
37
+ overflow: hidden;
38
+ background: var(--sv-color-surface-raised);
39
+ border: var(--sv-border-width-hairline) solid var(--sv-color-border);
40
+ border-radius: var(--sv-radius-lg);
41
+ box-shadow: var(--sv-shadow-card);
42
+ touch-action: none; /* always overridden inline with useSwipeStack's computed value */
43
+ user-select: none;
44
+ cursor: grab;
45
+ }
46
+
47
+ .card:active {
48
+ cursor: grabbing;
49
+ }
50
+
51
+ .stamp {
52
+ position: absolute;
53
+ z-index: 1;
54
+ display: flex;
55
+ align-items: center;
56
+ gap: var(--sv-space-1);
57
+ padding: var(--sv-space-1) var(--sv-space-3);
58
+ border: 2px solid var(--sv-color-text-primary);
59
+ border-radius: var(--sv-radius-md);
60
+ background: var(--sv-color-surface-raised);
61
+ color: var(--sv-color-text-primary);
62
+ font-family: var(--sv-font-family);
63
+ font-size: var(--sv-font-size-sm);
64
+ font-weight: var(--sv-font-weight-semibold);
65
+ pointer-events: none;
66
+ }
67
+
68
+ .stampUp {
69
+ top: var(--sv-space-4);
70
+ left: 50%;
71
+ transform: translateX(-50%);
72
+ opacity: var(--sv-stack-progress-up, 0);
73
+ }
74
+
75
+ .stampDown {
76
+ bottom: var(--sv-space-4);
77
+ left: 50%;
78
+ transform: translateX(-50%);
79
+ opacity: var(--sv-stack-progress-down, 0);
80
+ }
81
+
82
+ .stampLeft {
83
+ top: var(--sv-space-4);
84
+ left: var(--sv-space-4);
85
+ transform: rotate(-8deg);
86
+ opacity: var(--sv-stack-progress-left, 0);
87
+ }
88
+
89
+ .stampRight {
90
+ top: var(--sv-space-4);
91
+ right: var(--sv-space-4);
92
+ transform: rotate(8deg);
93
+ opacity: var(--sv-stack-progress-right, 0);
94
+ }
95
+
96
+ .actions {
97
+ display: flex;
98
+ justify-content: center;
99
+ gap: var(--sv-space-2);
100
+ flex: 0 0 auto;
101
+ }
@@ -0,0 +1,199 @@
1
+ 'use client';
2
+
3
+ import { Children, isValidElement, useEffect, useRef, useState } from 'react';
4
+ import type { ReactElement, ReactNode } from 'react';
5
+ import { Button } from '../Button/Button';
6
+ import { Icon, type IconName } from '../Icon/Icon';
7
+ import { useSwipeStack, SWIPE_STACK_TRANSITION_MS } from '../../hooks/useSwipeStack';
8
+ import type { SwipeDirection } from '../../hooks/useSwipeStack';
9
+ import { SwipeStackCard } from './SwipeStackCard';
10
+ import styles from './SwipeStack.module.css';
11
+
12
+ export interface SwipeDirectionMeta {
13
+ /** Visible text on the fallback button and the in-drag stamp. */
14
+ label: string;
15
+ icon?: IconName;
16
+ }
17
+
18
+ export interface SwipeStackProps {
19
+ /** Required — labels the swipeable region for assistive tech. No generic
20
+ * fallback, since one would be wrong for every actual consumer. */
21
+ 'aria-label': string;
22
+ /** Which of the 4 directions are live, and what each is called. A
23
+ * direction absent here cannot be dragged toward at all (useSwipeStack
24
+ * treats it as a wall) and gets no fallback button or stamp. SwipeStack
25
+ * itself has no opinion on what a direction *means* — that vocabulary
26
+ * belongs entirely to the caller. */
27
+ directions: Partial<Record<SwipeDirection, SwipeDirectionMeta>>;
28
+ /** Fires once a drag (or a fallback button) commits to a direction. Every
29
+ * configured direction behaves the same way: the card leaves for good —
30
+ * there is no reversible "go back" state. SwipeStack has no opinion on
31
+ * where card data lives; the caller is responsible for eventually no
32
+ * longer including this cardId in children (e.g. once a mutation lands). */
33
+ onSwipe: (direction: SwipeDirection, cardId: string) => void;
34
+ /** Effective drag distance (px) required to commit. Passed straight
35
+ * through to useSwipeStack. */
36
+ threshold?: number;
37
+ className?: string;
38
+ /** Must be SwipeStackCard elements (nullish/boolean children are safely
39
+ * skipped for conditional cards). A dev-mode warning is logged for any
40
+ * other child type — see resolveCards below. */
41
+ children: ReactNode;
42
+ }
43
+
44
+ interface ResolvedCard {
45
+ key: string;
46
+ element: ReactElement;
47
+ }
48
+
49
+ const DIRECTION_KEYS: readonly SwipeDirection[] = ['up', 'down', 'left', 'right'];
50
+
51
+ const STAMP_CLASS: Record<SwipeDirection, string> = {
52
+ up: styles.stampUp as string,
53
+ down: styles.stampDown as string,
54
+ left: styles.stampLeft as string,
55
+ right: styles.stampRight as string,
56
+ };
57
+
58
+ function describeChild(child: unknown): string {
59
+ if (isValidElement(child)) {
60
+ const type = child.type;
61
+ if (typeof type === 'string') return `<${type}>`;
62
+ if (typeof type === 'function') {
63
+ const named = type as { displayName?: string; name?: string };
64
+ return `<${named.displayName ?? named.name ?? 'Component'}>`;
65
+ }
66
+ return '<unknown>';
67
+ }
68
+ if (typeof child === 'string') return `string "${child}"`;
69
+ return String(child);
70
+ }
71
+
72
+ function resolveCards(children: ReactNode): ResolvedCard[] {
73
+ const cards: ResolvedCard[] = [];
74
+ Children.forEach(children, (child, index) => {
75
+ if (child === null || child === undefined || child === false || child === true) return;
76
+ if (!isValidElement(child) || child.type !== SwipeStackCard) {
77
+ if (process.env.NODE_ENV !== 'production') {
78
+ console.error(
79
+ '[SwipeStack] Every child must be a <SwipeStackCard>. ' +
80
+ `Found ${describeChild(child)} at position ${index}.`,
81
+ );
82
+ }
83
+ return;
84
+ }
85
+ const cardProps = child.props as { cardId: string };
86
+ cards.push({ key: cardProps.cardId, element: child });
87
+ });
88
+ return cards;
89
+ }
90
+
91
+ /**
92
+ * SwipeStack — a compound component for triaging one card at a time by
93
+ * dragging it left/right/up/down (wrapping useSwipeStack), plus an
94
+ * always-visible non-gesture fallback: a button per configured direction,
95
+ * wired to the identical commit path and exit animation as a real drag.
96
+ *
97
+ * Every direction is independently optional and behaves the same way once
98
+ * triggered — the card leaves for good, there is no reversible "go back".
99
+ * SwipeStack owns rendering, drag physics, and the stamp/fallback UI only;
100
+ * it has no opinion on where card data lives or what a direction means —
101
+ * see SwipeStackProps.onSwipe.
102
+ */
103
+ export function SwipeStack({
104
+ 'aria-label': ariaLabel,
105
+ directions,
106
+ onSwipe,
107
+ threshold,
108
+ className,
109
+ children,
110
+ }: SwipeStackProps) {
111
+ const cards = resolveCards(children);
112
+ const [dismissed, setDismissed] = useState<ReadonlySet<string>>(() => new Set());
113
+ const [exiting, setExiting] = useState<ResolvedCard | null>(null);
114
+ const exitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
115
+
116
+ useEffect(
117
+ () => () => {
118
+ if (exitTimerRef.current) clearTimeout(exitTimerRef.current);
119
+ },
120
+ [],
121
+ );
122
+
123
+ const available = cards.filter((c) => !dismissed.has(c.key));
124
+ const nextUp = available[0];
125
+ const current = exiting ?? nextUp ?? null;
126
+ const peek = exiting ? nextUp : available[1];
127
+
128
+ const liveDirections = DIRECTION_KEYS.filter((d) => directions[d]);
129
+
130
+ const { cardRef, wrapRef, handlers, touchAction, triggerCommit } = useSwipeStack({
131
+ directions: liveDirections,
132
+ threshold,
133
+ disabled: !!exiting || !nextUp,
134
+ onCommit(direction) {
135
+ const committed = current;
136
+ if (!committed) return;
137
+ setDismissed((prev) => new Set(prev).add(committed.key));
138
+ setExiting(committed);
139
+ if (exitTimerRef.current) clearTimeout(exitTimerRef.current);
140
+ exitTimerRef.current = setTimeout(() => {
141
+ setExiting((prev) => (prev?.key === committed.key ? null : prev));
142
+ }, SWIPE_STACK_TRANSITION_MS);
143
+ onSwipe(direction, committed.key);
144
+ },
145
+ });
146
+
147
+ return (
148
+ <div className={[styles.wrap, className].filter(Boolean).join(' ')}>
149
+ <div className={styles.stack} role="region" aria-label={ariaLabel}>
150
+ {peek && (
151
+ <div className={styles.peek} key={peek.key} aria-hidden="true">
152
+ {peek.element}
153
+ </div>
154
+ )}
155
+ {current && (
156
+ <div className={styles.cardSlot} key={current.key} ref={wrapRef}>
157
+ {liveDirections.map((d) => {
158
+ const meta = directions[d];
159
+ if (!meta) return null;
160
+ return (
161
+ <div
162
+ key={d}
163
+ className={[styles.stamp, STAMP_CLASS[d]].join(' ')}
164
+ aria-hidden="true"
165
+ >
166
+ {meta.icon && <Icon name={meta.icon} size="sm" aria-hidden />}
167
+ <span>{meta.label}</span>
168
+ </div>
169
+ );
170
+ })}
171
+ <div className={styles.card} ref={cardRef} style={{ touchAction }} {...handlers}>
172
+ {current.element}
173
+ </div>
174
+ </div>
175
+ )}
176
+ </div>
177
+ {liveDirections.length > 0 && (
178
+ <div className={styles.actions} role="group" aria-label={`${ariaLabel} actions`}>
179
+ {liveDirections.map((d) => {
180
+ const meta = directions[d];
181
+ if (!meta) return null;
182
+ return (
183
+ <Button
184
+ key={d}
185
+ variant="secondary"
186
+ size="sm"
187
+ disabled={!current}
188
+ onClick={() => triggerCommit(d)}
189
+ >
190
+ {meta.icon && <Icon name={meta.icon} size="sm" aria-hidden />}
191
+ {meta.label}
192
+ </Button>
193
+ );
194
+ })}
195
+ </div>
196
+ )}
197
+ </div>
198
+ );
199
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export interface SwipeStackCardProps {
4
+ /** Stable identity for this card, independent of its position among
5
+ * siblings — required. This is what SwipeStack reads (via child.props,
6
+ * not React's own `key`, which a component can't see on itself) to know
7
+ * which card is on top and what id to report through onSwipe. Reuse
8
+ * whatever id the caller already keys their `.map()` on. */
9
+ cardId: string;
10
+ children: ReactNode;
11
+ }
12
+
13
+ /**
14
+ * SwipeStackCard — a single card of a SwipeStack. Unlike
15
+ * SwipableMobileCarouselSlide, this has no mount-window logic of its own:
16
+ * SwipeStack only ever renders the top two cards' elements at all (there is
17
+ * no scroll-position reason to keep every sibling in the DOM the way the
18
+ * carousel's scroll-snap track does), so there is nothing here to gate.
19
+ */
20
+ export function SwipeStackCard({ children }: SwipeStackCardProps) {
21
+ return <>{children}</>;
22
+ }